Create a remote control for Black Mirror: Bandersnatch with PubNub
While watching Bandersnatch with a group of friends I encountered an unexpected issue. Often, the group was unable to come to a consensus before time ran out. To make watching Bandersnatch with a group easier, we’ll create a remote control designed specifically for Bandersnatch so that everyone can vote for their favorite option from their phone. This makes for a fun thematic twist when watching with others — nobody is completely in control of what path the group follows.
If you want to try out the final product first or would like to watch Bandersnatch now (there are spoilers later on), you can try out a live version of the project here. The full source for this project is available on GitHub.
A Chrome extension that connects to mobile devices via PubNub to allow remote control for Black Mirror Bandersnatch …
Spoiler Alert! There are some unique pathways which required the development of special cases. These pathways are mentioned throughout the project.
PubNub allows us to send device-to-device messages instantaneously using a publish/subscribe model. A Chrome extension detects the available options while Bandersnatch is playing and publishes them to a channel where the web app is subscribed. The web app displays the options it receives from the extension and waits for a selection. When an option is selected, it publishes it to a channel where the extension is subscribed and the extension records the vote. When time runs out for the choice, the extension will select the most popular option go back to looking for options.
Before we can start the project, we need to get our development environment set up, add some configuration files, and sign up for free PubNub keys.
Setup your development environment
You’ll also need Node.js so we can use NPM (Node Package Manager) to download some dependencies. The latest version is available for download here.
To test the project you’ll need a computer with Chrome and a Netflix account. Optionally, a mobile device will allow you to test the scanning feature of the web app.
To organize everything, start by creating a directory named
bandersnatch. This will contain all of the code, assets, and build tools for the project.
Get your PubNub keys
- Sign up for a PubNub account or login to an existing account from the PubNub dashboard
- Click the big red button labeled
CREATE NEW APP +
- Enter a name for your app (I went with “Bandersnatch PAC”) and click
- Select your new app and then select the
- Create a file named
keys.jsonand paste in the snippet below. Replace the
subscribeKeywith your keys from the PubNub dashboard
Specify a deploy path
Our extension and app will need to know where the website is hosted for generating and processing QR codes. Create
deploy-details.json and paste in the JSON below. When we deploy the web app at the end we’ll update this to reflect the publicly accessible URL.
Building the Chrome extension
We’re going to start with our extension because it handles most of the logic and interacting with Netflix.
Create a directory named
ext in the project directory. This is where we will put everything for the extension. In this directory, create
package.json with the following code and then
npm install. This might take a few minutes.
package.json has 3 important parts.
dependencies are packages that we use in our own code. We’re using
pubnub for real time messaging and
qrcode to generate QR codes.
devDependencies are packages that we use to build our code.
webpack does most of the heavy lifting of bundling our files together and allowing use to include packages from NPM in a browser application.
prettier to format our code and keep it readable.
scripts is a list of commands for building the extension and formatting the code.
npm run build will build and package the extension and
npm run lint will format the code with Prettier.
We also need to create
webpack.config.js so Webpack knows how to build our source code into the extension.
rules tell Webpack how to process files. We only have one rule, which tells Webpack to use Babel to process all of our
entry specifies the entry points for the project. Webpack starts at each entry point and bundles it with all of the imported modules. Each entry point corresponds to one output file.
plugins contains a bit of custom setup for working with extensions. The
CopyWebPackPlugin copies files from the
src directory into
dist. We use this to copy the HTML for the page action, images, and the extension manifest. There is also
ZipPlugin which creates a zip of the
dist directory. This is helpful because the Chrome web store requires a zip of the extension to submit for publication.
Chrome extension elements:
The extension manifest contains metadata like the name, icons, permissions, and scripts required for the extension.
Content scripts run in the context of a tab. They can interact with and mutate the DOM, but can’t use the features of the
Background scripts orchestrate the extension’s behavior and can access all of the extension APIs, but do not run in the tab’s context so they can not access the DOM.
Page actions provide a popup when the extension icon is clicked. They allow the extension UI to be separate from the website it is interacting with.
We’ll need to create a
src directory to put all our code and assets.
The extension manifest tells Chrome what tabs to inject content scripts into, where our files are, and the basic metadata. Our manifest defines a background script, a content script, and a page action. We’ll work on developing these next. Create
manifest.json and place it in
Chrome needs the icons provided in various sizes to support different screen resolutions. Create an
img directory and save the images from the repository. Alternatively, you can create your own icon in Illustrator and export with the
create subfolders option checked using scales of
16px to create all the necessary versions.
In order to make writing the extension easier I have provided some helper functions. Copy both files into
These files provide a few useful functions.
The background script does not run in the tab’s context, so it is best to use it for networking in order to avoid potentially impacting video playback. It’s the first part of the extension to run, so it also handles some house keeping like injecting content into the Netflix tab and creating a channel code.
For our background script we’ll need a file named
At the top of our file we want all of our imports. This pulls in some helper functions as well as PubNub and our PubNub keys.
The first thing we want to do is create a channel code. This is a unique identifier that will be used so that users can connect to the extension from the web app. It will also be the channel name used for PubNub. On startup, we’ll check to see if there’s already a code in
localStorage, if there isn’t we’ll generate a new code.
Then, we can use the helper functions to start a port for communicating with other tabs.
We also want our background script to handle requests to inject content scripts because our other scripts lack the necessary API access.
Now that we can talk to other tabs, we want to add a listener that will enable our page action when a tab requests it.
The most important task for the background script is recording and tallying votes. We can do this by putting the votes in a JSON object and then finding the most popular when time runs out. We have deal with our first special pathway here. In one scene there is a 5 digit phone number instead of a binary choice. This means we’ll get an array of 5 choices from the app instead of just 1. We need to handle this slightly differently by encoding the votes as a string during the vote counting process so we can use them as a key.
We’ve got all our vote counting figured out, but we still need to connect to PubNub and subscribe to our channel so we can actually get votes.
When we get a message that is a
choice we want to count the vote immediately. In the special case where we get a
multichoice our options will be an array instead, so they need to be stringified first.
Our content script will send two different types of messages to pass to the web app. A options message with the available pathways and an end message with the timestamp where voting will close. For both of these we want to immediately pass the message from the content script to PubNub.
The content script runs in the tab’s context which gives it access to the DOM. This allows it to detect changes in the page and extract the options to send to the background script. It also allows the extension to interact with the page to select options.
First we want to create a
start.js content script. This will check if the tab is actually Bandersnatch (not another title on Netflix) and then inject the main content script.
Our main content script goes in
interaction.js. Just like with the background script, we’ll start by importing some helper functions
Then, we’ll start a port to talk to the background script.
We also want the page action to be enabled, so we’ll ask the background script to enable the tab.
The challenging part is scraping the page to extract the information we need for the web app. For poking around in the page Chrome’s developer tools are invaluable. You can open developer tools with
ctrl/cmd+shift+i or by clicking the three dots in the top right and going to
More tools>Developer tools.
ctrl+shift+c allows you to inspect elements as you hover over them so you can see how Netflix renders the choices.
F8 will pause script execution, which allows you to pause the countdown and look through the DOM with out a 10 second limit. Adding breakpoints to events like
mousedown in the
Sources panel is also useful for debugging click interactions.
The first thing we have to figure out is how to determine the options that are on screen. It turns out that all the choices have the class
BranchingInteractiveScene--choice-selection. This means a simple query will return all the choices. Most of the time the choices are just
div elements with text inside. In one scene, the options are images instead. In this case, they instead contain another
div with the class
choiceImage which has a background image instead of text. Knowing this, we can support choices that are
text and choices that are an
image and adjust our behavior accordingly.
In addition to extracting the options, we want to be able to select the winner after voting. We can do this by faking a click on one of the elements and adding a listener to our port. Because of how Netflix built their UI, a simple
element.click() will not select an option. Instead, a
mousedown followed by
mouseup will simulate a click.
At this point, we aren’t running
getChoices yet because we don’t know when there will be choices. Instead of running our function over and over (and slowing down the page) we can use a
MutationObserver to watch for changes to the DOM. Our mutation observer will watch for any changes to the element with the class
nfp (Netflix player) and run our function
onPlayerChanged. We can wait 5 seconds to register this mutation observer at the start in order to not impact the initial player load.
onPlayerChanged function will determine if we should check for the options. When ever options become available an element with the class
main-hitzone-element-container is added, so we can wait for this mutation to
onPlayerChanged, we want to get the options and then add a temporary mutation observer to look for the
data-time-remaining attribute which Netflix uses to store the duration of the decision. This comes as much as a second or two later (because the options are added to the DOM before they are visible) so by sending it as a separate message we can ensure that the options are not delayed.
The page action presents the user with the domain of the remote app, the channel code, and a join link in the form of a QR code when the extension icon is clicked. This requires an HTML file and a small script.
index.html. All we need is some white text on a black background and a
canvas element for our QR code.
page-action.js we’ll get the channel code and use the
qrcode module to paint a QR code to the canvas in the page action.
… and we’re done with the extension!
To test it out you’ll need to build using the
webpack command. This should be run from
/ext/src. The built extension will be saved to
/ext/dist. Open Chrome and navigate to chrome://extensions. Make sure
Developer mode is turned on in the top right corner. Then, click
Load unpacked... and find your
/ext/dist directory. If you go to Netflix and start watching Bandersnatch the icon should turn red, and clicking it should open the page action.
Head back to the PubNub dashboard, select your project and then the
Demo Keyset. Select the
DEBUG CONSOLE on the left side and click the settings icon. Go to
Channels and paste the channel code from the page action for the
Publish Channel and
Subscribe Channel, then
UPDATE CLIENT. As you watch Bandersnatch, you should start to see options come in to the console shortly before they appear on screen, followed by a message with the end timestamp.
You can vote from the console by sending the
Building the web app
The second part of the system, the web app, handles displaying options and collecting votes. We’ll write the web app in Vue and use Vue Router to handle routing between the landing page and remote.
To make creating a web app with Vue easier, we can use the Vue Command Line Interface (Vue CLI).
npm i -g @vue/cli will install it globally on your machine. With the CLI installed,
vue create web from the project directory to make a new project.
For the preset choose
Manually select features and enable
CSS Pre-processors, and
Linter / Formatter. When prompted, select
Use history mode and choose
Sass/SCSS (with node-sass) for the CSS pre-processor and
ESLint + Prettier for the linter / formatter. Don’t change the
lint on save setting and place config
In dedicated files. You don’t need to save these settings as a preset.
It may take a few minutes to install all the dependencies and generate files for the project. Once the project has been created
cd web & npm i pubnub & npm i jsqr to install the rest of the dependencies for the web app.
Before we work on our UI, we need to adjust the generated project a little bit for our purposes. First, replace the contents of
router.js. This will change the routing paths for our app so that it defaults to using
/views/Join.vue for the default path and
/views/Remote for the
/remote path. It also will extract the channel as a parameter from the URL. For example, navigating to
/remote/1234-5678-9012 will pass us
We also need to adjust the
App component which contains the views from the router. We don’t have any page navigation that stays between views so our
App component just needs to hold the router view.
To make styling our app easier, we need a theme file that includes the colors and fonts for our app. The theme file should go in the
To be able to use these fonts we need to update our
index.html to load them from Google Fonts.
logo.png in the
views directory and
favicon.ico in the
public directory and we’ll be ready to get started with writing our views.
Join.vue contains the logic and UI for connecting to the extension. For Vue components there are three sections, the template (HTML), script (JS), and style (SASS).
The template for our Join component has 3
div elements which contain the sections of our UI. The
topbar contains the logo for the app and is always visible. The
scanner holds the video output from the camera, but is only enabled when we set
manual-entry component is rendered instead.
Syntax note: In Vue, the
v-if directive only renders an element if the expression evaluates to true. An element with
v-if=”false” would never be rendered and an element with
v-if="true" would always be rendered.
Understanding Vue: Expressions in our template are always evaluated in our Vue. This means that function calls will run a function from the
methods of our view and variables will come from the
computed fields of the Vue instance.
For the scanner, all we need is a
canvas to draw the camera output to and a message for the users.
To accept manual entry, the markup is a bit more complex. All of the input is going to go into a
form. Our form has event listeners so that we can respond to
delete, and the
right arrows. The form contains
input elements for accepting digits and a
button to submit. The
input is the most interesting element here. The inputs are rendered from a list with
v-for to keep our markup simple. Each is bound to a variable in our Vue assistance and has properties so that the numeric keyboard will show up on phones.
Syntax note: In Vue, the
@ directive is shorthand for
v-on. This adds an event listener using a function from the
methods on the Vue instance. For example,
@keydown.delete="backDigit" runs the function
backDigit whenever a keypress for delete or backspace occurs on the element.
Syntax Note: In Vue, the
: directive is shorthand for
v-bind. This binds a property or piece of content to a piece of
data on the Vue instance. This way, the DOM will update whenever a change is made to the
data from our scripts.
Syntax Note: In Vue, the
v-for directive allows list rendering. The element with the
v-for directive is rendered once for each item in the iterator and gains that item in its context. For example,
v-for="(digit, index) in digits" iterates through the array digits and renders the element each time. For each element that is rendered, the
digit from the array and the
index in the array are available to be bound to.
Syntax Note: In Vue, the
v-model directive performs two way data binding. Like with
v-bind, the value will update when the model is changed. Additionally, if the value of the input is changed the model will change as well.
Most of the work as far as handling the form and scanning happens in the script.
As always, we start by importing the dependencies that we need (this should go in a script tag in your component).
All of the logic goes inside the Vue instance. There’s 4 pieces worth looking at.
data is the heart of our Vue. The properties we put there become reactive, causing the markup bound to their values to re-render when anything changes. To start, we have an array for 12 empty
digits which are used to render the input fields with
v-for and are bound to the values of the input with
v-model. This way, when the input value changes or we change the value of the data, they are connected and change together. The other fields are
current, which tracks the current position in the 12 digits and
useCamera which dictates whether the camera or manual input will be used. The data is accessed inside our Vue through
methods fields contains functions for our app to use. This is where we will put our event listeners for key presses and DOM events (like focus and click). The first method we need is
formCode which navigates to the next page when it is submitted. To accomplish this, we take the values of all the input values and trim them to 1 digit, then add them to the path and insert a
— where needed.
The rest of the methods deal with treating our 12 separate inputs as 1. By listening to key presses and focus events from the form with
v-on we can move the cursor through the inputs as if we were dealing with a single field.
We also need a watcher. Watchers are a function that run whenever the piece of data on our Vue is updated. Our watcher will trim and validate any input and advance to the next input to make the transition between inputs seamless.
The most complex portion is the
mounted function which runs when the template has been added to the DOM. At this point, we can start dealing with the camera and render its output. Because not all devices have cameras, we need to check for one first. If there is no camera the video output is disabled and manual entry is used instead by setting
false. When the camera is available, each frame is fed into
jsQR to be processed for a QR code. When a code is found, it navigates to the next page.
Finally, we can add some styling using the color theme we created earlier. We don’t need to do anything particularly fancy except add some responsive breakpoints so the inputs will scale with the screen.
Unfortunately, we can’t test our app out quite yet because we’re missing the
Channel (remote) page
Remote component is pretty simple, and once we finish it up we’ll be ready to try everything out.
This time our
template isn’t quite as complex. At the top there is a countdown bar which mirrors the countdown on screen. Below, it renders items from the
options and depending on whether the
text or an
image it displays the
option.data as text or uses it as the URL for an image. In the case of scenes where multiple selections or required, it displays the selected options as the user enters them.
The script for our component handles connecting to PubNub and sending and receiving messages. When a message is received with
options it, updates the Vue data. When a message with a
timeout is received, the countdown is started. Using a click listener, the selected option is sent back the the extension and input is disabled to prevent one user casting multiple votes. There’s nothing too interesting going on here, so it’s not worth digging into the code.
Finally, we can add some styles to mimic the Bandersnatch UI.
With all our components complete, the web app is ready to test with
npm run serve. Navigating to http://localhost:8080/ should take you to the join page. Unfortunately, we can’t access the camera without HTTPS, so we can only use manual entry. To try out code scanning, we’ll have to deploy the app behind HTTPS. Ee’ll use Netlify for the sake of simplicity, so we don’t have to deal with servers or hosting.
Deploy with Netlify
Before we deploy, we can add a few commands to our
package.json files that will handle tasks like installing dependencies and building our source to make development easier in the future.
In the project directory, run
npm init and follow the prompts to create an empty
Then, add these fields to the
scripts section. They allow us to lint and build the extension and the web app from the project directory. They also add a
netlify command which we will need to deploy our site.
scripts section of
ext/package.json add these scripts to lint and build the extension. They aren’t required to deploy the web app, but they’ll make rebuilding the extension more convenient in the future.
Finally, to complete our
netlify command, we need to create the script that is run by
make:keys to generate
netlify-keys.js takes the environment variables during a build and writes them to
keys.json. Using this script, we can insert our keys into our Netlify build even though
keys.json will be ignored by git.
To deploy to Netlify we need to setup version control with git. In the project directory add a
.gitingnore file. This tells git to not commit the built versions, npm packages, and
keys.json because we don’t need this files tracked in git. and run
git init. Then, login to your preferred git provider (GitHub, GitLab, or BitBucket) and create a new repository. They should give you URL for your repository that ends in
git remote add origin https://provider.com/repository.git, replacing
https://provider.com/repository.git with the URL from your git provider. Then,
git add --all & git commit -am "initial commit" & git push to add all the files to your repository.
Now, login or sign up for a Netlify account. Click
New site from Git, select your git provider, and then link your account. You should see a list of repositories. Select the repository you created for this project. For the
Build command enter
npm run netlify and for
Publish directory enter
web/dist. Open the
Advanced options and click
New variable twice. For one, make the key
publishKey and for the other make it
subscribeKey. Fill in the values with your keys from
keys.json. Because we don’t commit our
keys.json, it needs to be recreated on the build server from these environment variables. This is handled by the
netlify-keys.js script we created earlier. Click
Deploy site to put it online. You should get a domain where you project is hosted and be able to visit the web app in your browser.
localhost:8080 with the Netlify URL in
deploy-details.json and commit the changes with
git commit -m “update public path" & git push origin master. This allows the QR code to be scanned by any QR code scanner instead of only working with the scanner within the web app.
Anybody to can access the site now, and can join in if you share the code from your extension. To give others access to the extension you’ll need to share a the zip from
ext/dist with them. You can also submit it to the Chrome webstore, but the submission process is out of the scope for this write up.
With your site deployed, you can open the web app from your browser. If you enter or scan the code from your extension the app will connect and your can start controlling Bandersnatch from your phone.