{
    "componentChunkName": "component---src-components-post-js",
    "path": "/blog/2020/06/16/unshattering-the-audience-building-theatre-on-the-web-in-2020",
    "result": {"data":{"mdx":{"id":"b3d872f6-2da5-577a-8c6d-a51752a9cd0f","body":"var _excluded = [\"components\"];\n\nfunction _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }\n\nfunction _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }\n\nfunction _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }\n\n/* @jsxRuntime classic */\n\n/* @jsx mdx */\nvar _frontmatter = {\n  \"title\": \"Unshattering the Audience: Taking Theatre Online with WebGL and WebRTC\",\n  \"date\": \"2020-06-16T00:00:00.000Z\"\n};\n\nvar makeShortcode = function makeShortcode(name) {\n  return function MDXDefaultShortcode(props) {\n    console.warn(\"Component \" + name + \" was not imported, exported, or provided by MDXProvider as global scope\");\n    return mdx(\"div\", props);\n  };\n};\n\nvar AnchorLink = makeShortcode(\"AnchorLink\");\nvar ShatteredCards = makeShortcode(\"ShatteredCards\");\nvar layoutProps = {\n  _frontmatter: _frontmatter\n};\nvar MDXLayout = \"wrapper\";\nreturn function MDXContent(_ref) {\n  var components = _ref.components,\n      props = _objectWithoutProperties(_ref, _excluded);\n\n  return mdx(MDXLayout, _extends({}, layoutProps, props, {\n    components: components,\n    mdxType: \"MDXLayout\"\n  }), mdx(\"p\", null, mdx(\"em\", {\n    parentName: \"p\"\n  }, \"[\", mdx(\"strong\", {\n    parentName: \"em\"\n  }, \"Ed. Note:\"), \" This post contains a promotion! It basically \", \"*\", \"is\", \"*\", \" a promotion for my current online interactive show: Shattered Space.]\")), mdx(\"p\", null, mdx(\"em\", {\n    parentName: \"p\"\n  }, \"[But I've also got a lot of cool technical stuff to tell you about, so stick around and if this sounds interesting feel free to \", mdx(\"a\", {\n    parentName: \"em\",\n    \"href\": \"https://shatteredspace.live\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"buy a ticket here\"), \"!]\")), mdx(\"h2\", {\n    \"id\": \"table-of-contents\"\n  }, mdx(\"a\", {\n    parentName: \"h2\",\n    \"aria-hidden\": \"true\",\n    \"tabIndex\": -1,\n    \"href\": \"#table-of-contents\"\n  }, mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"icon icon-link\"\n  })), \"Table of Contents\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(AnchorLink, {\n    to: \"#intro\",\n    mdxType: \"AnchorLink\"\n  }, \"Intro\")), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(AnchorLink, {\n    to: \"#other-peoples-computers\",\n    mdxType: \"AnchorLink\"\n  }, \"Other People's Computers\")), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(AnchorLink, {\n    to: \"#basic-platform-overview\",\n    mdxType: \"AnchorLink\"\n  }, \"Basic Platform Overview\")), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"WebRTC/Janus\", mdx(\"ul\", {\n    parentName: \"li\"\n  }, mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(AnchorLink, {\n    to: \"#webrtc-and-janus\",\n    mdxType: \"AnchorLink\"\n  }, \"WebRTC and Janus\")), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(AnchorLink, {\n    to: \"#webrtc-and-redux\",\n    mdxType: \"AnchorLink\"\n  }, \"WebRTC and Redux\")), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(AnchorLink, {\n    to: \"#acquiring-media-devices\",\n    mdxType: \"AnchorLink\"\n  }, \"Acquiring Media Devices\")))), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"WebGL/three.js\", mdx(\"ul\", {\n    parentName: \"li\"\n  }, mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(AnchorLink, {\n    to: \"#threejs-and-offscreencanvas\",\n    mdxType: \"AnchorLink\"\n  }, \"three.js and OffscreenCanvas\")), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(AnchorLink, {\n    to: \"#threejs-and-memory-management\",\n    mdxType: \"AnchorLink\"\n  }, \"three.js and Memory Management\")))), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(AnchorLink, {\n    to: \"#detecting-and-solving-issues-remotely\",\n    mdxType: \"AnchorLink\"\n  }, \"Detecting and Solving Issues Remotely\")), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(AnchorLink, {\n    to: \"#come-see-shattered-space\",\n    mdxType: \"AnchorLink\"\n  }, \"Come See Shattered Space!\"))), mdx(\"h2\", {\n    \"id\": \"intro\"\n  }, mdx(\"a\", {\n    parentName: \"h2\",\n    \"aria-hidden\": \"true\",\n    \"tabIndex\": -1,\n    \"href\": \"#intro\"\n  }, mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"icon icon-link\"\n  })), \"Intro\"), mdx(\"p\", null, \"Safe to say that the past couple months have gone a lot different than many of us were expecting. In early March, as quarantine was starting and all of my projection design gigs were being cancelled, I hit up a couple close friends about building a non-linear theatre experience entirely online. A lot of folks have been trying to perform plays on Zoom (and have found a lot of interesting and clever ways of working with that medium), but I felt like we could make something really unique if we built our own custom video conferencing app.\"), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/1162e31e16e66b4304d2476959c01cd3/8dc0b/initial-google-doc.png\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"54.85714285714286%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAALCAYAAAB/Ca1DAAAACXBIWXMAABYlAAAWJQFJUiTwAAABjUlEQVQoz3VT7W7CMAzs+z/DXmZvMCgg+FUoLWkZ0Cal+U5vsrVuDLFIJydWfPHZcVZdHOqqwvV6hVIKxhh47xnWOngfQGuaJsyL9iklpES+Cavyjrd3hWW+RdbLAb1UkGqAlAq3TqJpz7hcLqjFJ+r6BCEalMcKZdXgfD5j1AbDcIca7tDagDjkoKGNRTaOGsY6PsSYoI3Bx8cCy+USqzzHYrFg5HmOfVHgdrshhMgZznFstYHWAzLrPAjkDDGx1LIscTgccDwe2dK5rmu0bYvT6YRxHFm6dQ5z/GgsXEjIqE4zIWVorcVms2Gs12tst1vsdjsmpewI9CjVkWLMQ/w0JWSPjhAjQgiciRCCs6E91Y3OZPu+Z7lEaJ8IyZ9RgWeHD4Evxhhfgh4jSw2n4DnuX0LnA9LD9/hvpe9HqYbG+mdC+yuZuxfRdR1Lmy1BScmgffhWYl5Ltn9q6L1DURTY7/dsCdTtVgiIqkLbNE+E/qfTlPkLQv9DSF9mBn106jBNE0/JU1O6XvJUfQEUz0z7SRr75AAAAABJRU5ErkJggg==')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"The Google Doc that started it all\",\n    \"title\": \"The Google Doc that started it all\",\n    \"src\": \"/static/1162e31e16e66b4304d2476959c01cd3/f847a/initial-google-doc.png\",\n    \"srcSet\": [\"/static/1162e31e16e66b4304d2476959c01cd3/f73d0/initial-google-doc.png 175w\", \"/static/1162e31e16e66b4304d2476959c01cd3/b4640/initial-google-doc.png 350w\", \"/static/1162e31e16e66b4304d2476959c01cd3/f847a/initial-google-doc.png 700w\", \"/static/1162e31e16e66b4304d2476959c01cd3/a94c1/initial-google-doc.png 1050w\", \"/static/1162e31e16e66b4304d2476959c01cd3/b5909/initial-google-doc.png 1400w\", \"/static/1162e31e16e66b4304d2476959c01cd3/8dc0b/initial-google-doc.png 2880w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"The Google Doc that started it all\"), \"\\n  \"), mdx(\"p\", null, \"Crazy? It definitely would've been 10 years ago. But advances in the web platform (as well as the quality of cameras and hardware in mobile devices) took this down into the realm of \\\"possible-if-slightly-quixotic\\\".\"), mdx(\"p\", null, \"Three months later, we've done it: Shattered Space is a piece of interactive theatre about a binary star system whose twin stars (\\\"The Mothers\\\") have disappeared. You and your fellow audience members are Star Jockeys, and have been conscripted to fly around in spaceships to get to know the denizens of the Matra System and see what you can do to help.\"), mdx(\"p\", null, mdx(\"undefined\", {\n    parentName: \"p\"\n  }, \"\\n        \", mdx(\"div\", {\n    \"className\": \"embedVideo-container\"\n  }, \"\\n            \", mdx(\"iframe\", {\n    parentName: \"div\",\n    \"title\": \"\",\n    \"width\": 700,\n    \"height\": 400,\n    \"src\": \"https://www.youtube.com/embed/iifZPEHJM6c?rel=0\",\n    \"className\": \"embedVideo-iframe\",\n    \"style\": {\n      \"border\": \"0\"\n    },\n    \"loading\": \"eager\",\n    \"allowFullScreen\": true,\n    \"sandbox\": \"allow-same-origin allow-scripts allow-popups\"\n  }), \"\\n        \"))), mdx(\"p\", null, mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"Here's a trailer if you want a better idea of what's going on\")), mdx(\"p\", null, \"If you watched that trailer, you've probably figured out that this show involved 3D graphics (WebGL), videoconferencing (WebRTC) and perhaps also some music and audio processing (Web Audio). These were all technologies that I'd worked with on some level in the past, but this show required me to think about them completely differently. All because of the following central problem:\"), mdx(\"h2\", {\n    \"id\": \"other-peoples-computers\"\n  }, mdx(\"a\", {\n    parentName: \"h2\",\n    \"aria-hidden\": \"true\",\n    \"tabIndex\": -1,\n    \"href\": \"#other-peoples-computers\"\n  }, mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"icon icon-link\"\n  })), \"Other People's Computers\"), mdx(\"p\", null, mdx(\"undefined\", {\n    parentName: \"p\"\n  }, \"\\n        \", mdx(\"div\", {\n    \"className\": \"embedVideo-container\"\n  }, \"\\n            \", mdx(\"iframe\", {\n    parentName: \"div\",\n    \"title\": \"\",\n    \"width\": 700,\n    \"height\": 400,\n    \"src\": \"https://www.youtube.com/embed/nGt8w9RbhvY?rel=0\",\n    \"className\": \"embedVideo-iframe\",\n    \"style\": {\n      \"border\": \"0\"\n    },\n    \"loading\": \"eager\",\n    \"allowFullScreen\": true,\n    \"sandbox\": \"allow-same-origin allow-scripts allow-popups\"\n  }), \"\\n        \"))), mdx(\"p\", null, mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"This WebRTC-based monitoring system allowed the backstage band to see the stage in real time and take cues from actors. The onstage portal was rendered with WebGL. (\", mdx(\"em\", {\n    parentName: \"strong\"\n  }, \"Welcome to Shakesville\"), \", Baltimore Rock Opera Society 2019)\")), mdx(\"p\", null, \"I've used WebGL and WebRTC to build projection/video effects for a number of shows the past couple years, but in every case I was writing code that would only run on \", mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"my\"), \" computer. If I ran into a device-specific bug that affected my computer (or my iPhone, or my squad of Raspberry Pis) I had all the time and access I needed to either debug it or come up with a workaround. Not this time.\"), mdx(\"p\", null, \"If someone pays us $15, receives the login link via email, tries to open it on a cheap 2014 laptop and it can't load without crashing, that's a refund. We weren't just messing around with advanced web features, we needed to deploy them in the most battle-hardened and resilient way possible, while also capturing just enough data and logs to pinpoint who was having problems and ideally solve them before the show started.\"), mdx(\"p\", null, \"This meant going beyond the kinds of code you see in introductory tutorials and really digging into how these APIs deal with memory management, how they respond to connection issues, how they can fail, and how to tweak things at runtime to ensure that framerates stay high and dropped packets are few. I learned a lot about productizing these features, and I'd like to share as much of that as I can, but first let's talk basics.\"), mdx(\"h2\", {\n    \"id\": \"basic-platform-overview\"\n  }, mdx(\"a\", {\n    parentName: \"h2\",\n    \"aria-hidden\": \"true\",\n    \"tabIndex\": -1,\n    \"href\": \"#basic-platform-overview\"\n  }, mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"icon icon-link\"\n  })), \"Basic Platform Overview\"), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/319746c592707c3b5ca96e96686dc32e/ea415/ship-in-flight.png\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"65.14285714285714%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAANCAYAAACpUE5eAAAACXBIWXMAAAsTAAALEwEAmpwYAAADS0lEQVQ4y23Qy09cZRiA8ePSlTFFWu6h7XBvKmfOwMyZmXPmXBFB6HBpKZdQQErlIqYQaE0dQFrUgcoUho4tUqBl2rS1baKxjYmJLly4dNHEhStRmAH/iMfMEBcaF798m/d98uYTtneT7Cb2SST32Ukk2dv7i71kgu3EDn8md/kjuUtq5ved/9g9sJ3YS787iQS/vHyJcO/+Fnfj94it3ubGSpSVWJRIZI2F+cdEFh+ztPiU1S+3uLO5zp2NjbT1zU1it2+xHI2md5ZiN7kVXWDm2ocIolMkRXJJSJILt0ekIEtFEE6T+eoFcl7pofi4G5erElF0IooikiRRXlpGhaOE/LwCMg8f4fVDb+ByuxEsyyLFNE1Mw6KmVkcqbyHrtRCnjO85WxDD66rDrjEwTSvNtmwUVUX2+SkpLaOouIScnFzO9fQi6LrOPzTNwLIV3izrwJFxjTbrGwL5V/FJdehmAE1LzWgYhoEsy4hOiWPHHWnZ2bk0t7b+X9DPieIuKg7NU5gZovBwCNXTgm4o/wq63R4cRcUUHj12EMzJpbEpiKClYoaRZugGXl1h0G6jNiuClL2GLy+GRwpi2gcX6pqW/h7Z66O84kQ6mAqngi1nziAYmoaq+FEVBVs3kHUPl5X3Gcv4mbbM32jJ+BX/yXYUtRo9daGupWedTifFpWUUFh4lLy+frCNZ+BQ/gmzatHZ20drRQbVmUG0q9J/+lCvBHzlbvU63+oDe7gk6+7oJmDamGqCrt5f27m4MRaFS9mA2NqDX11MkViJ0DQ4wMz/L2PQUo1Mh5sKX+GR5gtnli4xOD/BuaJwPZsOEPo/QcX6cucUlrm/FmVpZ5cLwGJVehSotQJWmUST7ET6LhFlfizLS9x7PXnzF9v5PxONzTI4PsRKe5OOPQiwsr3DlRpyRoRk2Htzn+t1HXP3iIedHL+OSvaiqC8tScUgehHD0JgNDEwz2DLMVD/Pk+TptTc14DJNz7U10tjbTPzzC5FyMyfFpato6MS8uYo1G6OsfRnRXodf40E2d6pomhCdPn/Ht18958d0PXJqexm3X4vAalAYsPLUN+N9pxl0XxF93Cqs+iK3byGYDstlI0H4bKWBSab2Fy65Fsuv4G6UwKMcEpzYEAAAAAElFTkSuQmCC')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"A group of attendees interacting with a character in the show\",\n    \"title\": \"A group of attendees interacting with a character in the show\",\n    \"src\": \"/static/319746c592707c3b5ca96e96686dc32e/f847a/ship-in-flight.png\",\n    \"srcSet\": [\"/static/319746c592707c3b5ca96e96686dc32e/f73d0/ship-in-flight.png 175w\", \"/static/319746c592707c3b5ca96e96686dc32e/b4640/ship-in-flight.png 350w\", \"/static/319746c592707c3b5ca96e96686dc32e/f847a/ship-in-flight.png 700w\", \"/static/319746c592707c3b5ca96e96686dc32e/ea415/ship-in-flight.png 1000w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"A group of attendees interacting with a character in the show\"), \"\\n  \"), mdx(\"p\", null, \"Attendees of Shattered Space are broken up into 6 groups of 5. Each group is assigned to a \\\"ship\\\" that flies around the system, and they interact with the characters in the show as a group. Attendees have the options to mute their microphone and turn off their camera if they wish (there is a text chat on the side they can use to communicate) although we encourage people to keep them on if they feel comfortable.\"), mdx(\"p\", null, \"The Shattered Space platform consists of:\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"An ExpressJS service that keeps track of the overall state of the show.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"7 Janus WebRTC media servers. One for each ship and a extra for broadcasting a livestream of the ending scene.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"The Admin App, a React/Redux webapp that handles scheduling shows, keeping track of which actor is talking to which ship, and has real-time dashboards with logs of client-side errors and connectivity issues.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"The Actor App, a React/Redux webapp used by actors to interact with the audience via audio, video, text, and the giving/taking of items from a ship's inventory.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"The Attendee App, a React/Redux webapp where attendees view the show and interact with the actors and each other via audio, video and text.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"The Host App, used by me to perform the motion-captured ending scene.\")), mdx(\"p\", null, \"When I say that the Show Service \\\"keeps track of the overall state of the show\\\", I mean \", mdx(\"em\", {\n    parentName: \"p\"\n  }, \"all\"), \" of the state. Most of the Redux actions (across all 3 apps) don't mutate the state of the reducers, but instead hit an endpoint on the Show Service that changes the state kept in the database, then fetches \", mdx(\"em\", {\n    parentName: \"p\"\n  }, \"all\"), \" of the state from the database and sends it to \", mdx(\"em\", {\n    parentName: \"p\"\n  }, \"all\"), \" of the users as a WebSocket message which then overwrites most of the reducers' state. This way, no client can end up with \\\"orphan state\\\" as a result of optimistically updating their reducer before their API call returns, and fetching the updated state for \", mdx(\"em\", {\n    parentName: \"p\"\n  }, \"all\"), \" users can be done with a single database call (this refresh-all-state-for-everyone method is debounced and implements a queueing mechanism to ensure we don't have too many database calls at once).\"), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/affcca5a49cab59b1ebcc6710c877fcf/475eb/redux-oops.jpg\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"33.714285714285715%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/jpeg;base64,/9j/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wgARCAAHABQDASIAAhEBAxEB/8QAFwABAAMAAAAAAAAAAAAAAAAAAAIEBv/EABUBAQEAAAAAAAAAAAAAAAAAAAEC/9oADAMBAAIQAxAAAAHZxGbAH//EABgQAQEAAwAAAAAAAAAAAAAAAAISAAER/9oACAEBAAEFAo5hKk6k/wD/xAAXEQEAAwAAAAAAAAAAAAAAAAAAARES/9oACAEDAQE/Acwp/8QAFREBAQAAAAAAAAAAAAAAAAAAEBH/2gAIAQIBAT8Bp//EABkQAAIDAQAAAAAAAAAAAAAAAAAhERKSgf/aAAgBAQAGPwJW0O2iH0//xAAZEAEAAwEBAAAAAAAAAAAAAAABABEhUXH/2gAIAQEAAT8hNrMgyAL7aFoXq5//2gAMAwEAAgADAAAAEPP/AP/EABkRAQACAwAAAAAAAAAAAAAAAAEAESFBcf/aAAgBAwEBPxDIVrkAFE//xAAXEQADAQAAAAAAAAAAAAAAAAAAAREh/9oACAECAQE/EL0bus//xAAaEAEBAAMBAQAAAAAAAAAAAAABEQAhMUFx/9oACAEBAAE/EEXSmq3pnuUE6SqTtw8GdVVfrn//2Q==')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"I basically took Dan Abramov's simple and elegant system and did this to it.Sorry Dan.\",\n    \"title\": \"I basically took Dan Abramov's simple and elegant system and did this to it.Sorry Dan.\",\n    \"src\": \"/static/affcca5a49cab59b1ebcc6710c877fcf/43804/redux-oops.jpg\",\n    \"srcSet\": [\"/static/affcca5a49cab59b1ebcc6710c877fcf/6b97f/redux-oops.jpg 175w\", \"/static/affcca5a49cab59b1ebcc6710c877fcf/86423/redux-oops.jpg 350w\", \"/static/affcca5a49cab59b1ebcc6710c877fcf/43804/redux-oops.jpg 700w\", \"/static/affcca5a49cab59b1ebcc6710c877fcf/518fe/redux-oops.jpg 1050w\", \"/static/affcca5a49cab59b1ebcc6710c877fcf/e3000/redux-oops.jpg 1400w\", \"/static/affcca5a49cab59b1ebcc6710c877fcf/475eb/redux-oops.jpg 1682w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"I basically took Dan Abramov's simple and elegant system and did this to it.Sorry Dan.\"), \"\\n  \"), mdx(\"p\", null, \"(I'd note that this architecture emerged sort of organicly and there are probably cleaner or more optimal ways of achieving this \\\"Redux-but-the-server-is-the-reducer\\\" architecture. I'm open to feedback on whether something like Relay or Meteor would be a better fit for a V2)\"), mdx(\"span\", {\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/fdc0b917a1992700ca56537fb42b9e94/40d2f/admin-show-online.png\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"74.28571428571428%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAPCAYAAADkmO9VAAAACXBIWXMAABJ0AAASdAHeZh94AAABhklEQVQ4y5WUzW6DMBCEeaCecm4BY4OJbf4hadKc8kB9jL7mVLPCVdQ2JT2MbCH4PLO7JlGlQtu1GIYBh8MBzjloY1Bpjdc0xZhlUEpBFQUKpVBw/UV8R2uNpCorgQ19jxAC9vs9XGgwGoOPpye873bIN4Bqfc41adsWy7Jgnmc0TQPvPeq6hvMBz8bgWa0fRugfDrkm/DiCuq6T2HxWliVMUUDH0zciRyX9MOB6vUrtCOFqjEG21u7r9KhNYNfhfDoJJAK4Z4FvozyqpEpTBFujshZ5nkvcaZqkQdbaH8CtA5K60OhZwxBQVZVEZk0JplMeImOzKkLvgZO9dzi/veFyuWBeFvR9L9AYnXWNui3LXYd5/gLvnXQ5zzKJych0GbtPsQR0vQm0aYqmrgWUrUAOOl2yBLHbMfpmZG8tjvOMcZpkDgnjoHPPTkfQw0BVFhjmEcfDUa4dYzLybVSqkWv5QGSdvqBxTiLSBSOP4ygwAtiIKDreBAbvxR0jxj/G98H+z9h8AiKLH0YaMl/pAAAAAElFTkSuQmCC')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"admin show online\",\n    \"title\": \"admin show online\",\n    \"src\": \"/static/fdc0b917a1992700ca56537fb42b9e94/f847a/admin-show-online.png\",\n    \"srcSet\": [\"/static/fdc0b917a1992700ca56537fb42b9e94/f73d0/admin-show-online.png 175w\", \"/static/fdc0b917a1992700ca56537fb42b9e94/b4640/admin-show-online.png 350w\", \"/static/fdc0b917a1992700ca56537fb42b9e94/f847a/admin-show-online.png 700w\", \"/static/fdc0b917a1992700ca56537fb42b9e94/a94c1/admin-show-online.png 1050w\", \"/static/fdc0b917a1992700ca56537fb42b9e94/b5909/admin-show-online.png 1400w\", \"/static/fdc0b917a1992700ca56537fb42b9e94/40d2f/admin-show-online.png 1697w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/b2a018c917f62bbf28c76e35d5f2fe09/19bb1/actor-screen.png\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"49.142857142857146%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAKCAYAAAC0VX7mAAAACXBIWXMAABJ0AAASdAHeZh94AAABfklEQVQoz3WS0U7jQAxF+xEb+o6EtNK2Y0+aSTITkpnQpoUVICGqhZ/gjX+hWhZt2RfEV97VOK3UFng4up6RfWXLHlz8esH05hnz5R9c3a1xc/+KxfIF3e0zmssVTn+uEK5/i1YXT1+wgjt/kryBUgwijajdtMOim8ubWIM5BYtu370eEv8VMYwpMGAmRBQp1LlBVTmEENA0Ddq2V+ec5BARmHrdReqVgjEmGjIiigh2MkFhcjjr4KxF5SoxK8sSOnZC/Dkcp4wd5juGSsHmBsY5nLatUIUA2zTyN9IaI+Y9fjBjzFtD+tihLwwWWYaFMZhnGc6N6ePJBH48RjMawW/YxqVSoGi4O7Imwnet8XhygvfhEH+TBP+OjrBOEqy/JaKvB8SctyTBw/Gx1LJSyPODkX2WYeo9Zm2LUNfozs4wCwFtHDtNkSqFlGgPLUvp6/cN41KyDDYuwVohxpG8KPqxvuBzw7iUMkcbgpyN915Opq5r2bjkbc7mAzuG/wGuOk32sZPDYwAAAABJRU5ErkJggg==')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"The Admin app allows us to see who's logged on and what's happening in the show\",\n    \"title\": \"The Admin app allows us to see who's logged on and what's happening in the show\",\n    \"src\": \"/static/b2a018c917f62bbf28c76e35d5f2fe09/f847a/actor-screen.png\",\n    \"srcSet\": [\"/static/b2a018c917f62bbf28c76e35d5f2fe09/f73d0/actor-screen.png 175w\", \"/static/b2a018c917f62bbf28c76e35d5f2fe09/b4640/actor-screen.png 350w\", \"/static/b2a018c917f62bbf28c76e35d5f2fe09/f847a/actor-screen.png 700w\", \"/static/b2a018c917f62bbf28c76e35d5f2fe09/a94c1/actor-screen.png 1050w\", \"/static/b2a018c917f62bbf28c76e35d5f2fe09/b5909/actor-screen.png 1400w\", \"/static/b2a018c917f62bbf28c76e35d5f2fe09/19bb1/actor-screen.png 2557w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"The Admin app allows us to see who's logged on and what's happening in the show\"), \"\\n  \"), mdx(\"p\", null, \"Of the 3 React/Redux apps, the Admin App uses the least of these shiny web technologies, and is pretty much just a dashboard for displaying what's in the database. As admins, we can see which actors and attendees have logged on, which ships are currently visiting which actors, we can move attendees between ships if need be, and we can see a list of errors the actors and attendees have encountered (more on this later).\"), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/274a18a19682806bc74f478c0dcabd1f/a4b11/actor-app-normal.png\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"49.71428571428571%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAKCAYAAAC0VX7mAAAACXBIWXMAABJ0AAASdAHeZh94AAACeklEQVQoz02Sz2/SYByHWTBx02jUZGwMKO3bdqUdc9Ax2BQYjFEqrKj7gW5uAoozOnUmXjxq1HhRFxP15FUvO2p28ORJ/6zHlDnj4XN4D++Tz/PJN/Csk+RFR6YyZ3Kp2uCq53F9fYNOt8t25yYrvR6pkkfenmd5rYVbb3AxX6RQXqDiVElaFtmpFIauUZ4vEli8YFK0Y6SSBo2ax5WlJjfWN+i1t+htbVCybcyRESYsk/b1Fs3yPJYUJjOTo+I4mIZBJm2jaRpurUZgLKowFhMYhsmSe5lmo8n6WoudbptmzeFeoczXzh1CJ09TKS30gWldolRdYmHRByaYyWQwjAROtUpAFQIhFEzTou5eoeF6tK4uc3drk8LsLLv5PD8fP2X1fIZJM4mTzzMphcjNFSlXG5iJBNk+0KCQv0hAURSEEOiaTqVUx3WaePUm3Y2b5FI2rqbwceUai5ZNLByllCtgRULMZAuUFj2McQNNCOKSRDo1dQhUNRUpGkMeFUxNZMhmirjOZc4bExwPBpnTE9iKztDgCUIhiXhEIZe/RLHiMa6P9w0lKY6dTv9tqAqSVhJ1TCN8LsypobMMHjvJQCBI8NgpRkMGM4ksQ4OnGQgOcXzwDIlEBtOcRld1/Nkkv6EPVFWVSCTC23d7/Pr1m703eyw5dW51e3TaPdqb21jJOc4Ny4yMRBgOhRkOjSHHZORIDKEoaOqhsp1O+UBBNBrj/qOHHBz84NPr97x6/pIv+/t82//O53cfcF2XWDSCrutomtqPbyVUtb+/ONrQb+g/ZFmm5d/ezhNma7dxvVV2dh/jXXvAheom09PTSPH4v89+/KmOIv5T/gPi1EyvVQWO2AAAAABJRU5ErkJggg==')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"The actor app allows performers to engage with the audience and view information about the ship they're talking to\",\n    \"title\": \"The actor app allows performers to engage with the audience and view information about the ship they're talking to\",\n    \"src\": \"/static/274a18a19682806bc74f478c0dcabd1f/f847a/actor-app-normal.png\",\n    \"srcSet\": [\"/static/274a18a19682806bc74f478c0dcabd1f/f73d0/actor-app-normal.png 175w\", \"/static/274a18a19682806bc74f478c0dcabd1f/b4640/actor-app-normal.png 350w\", \"/static/274a18a19682806bc74f478c0dcabd1f/f847a/actor-app-normal.png 700w\", \"/static/274a18a19682806bc74f478c0dcabd1f/a94c1/actor-app-normal.png 1050w\", \"/static/274a18a19682806bc74f478c0dcabd1f/b5909/actor-app-normal.png 1400w\", \"/static/274a18a19682806bc74f478c0dcabd1f/a4b11/actor-app-normal.png 2560w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"The actor app allows performers to engage with the audience and view information about the ship they're talking to\"), \"\\n  \"), mdx(\"p\", null, \"The Actor App is more sophisticated, since it needs to handle WebRTC and media device concerns. It also has 3 modes: Normal, Mobile (used automatically if the window has a dimension less than 500px) and Headless (used if the querystring variable \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"headless\"), \" is set to \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"true\"), \". The Normal mode is used by about half of the actors, and has the ability for them to see themselves, configure their audio/video devices, change any info about their user, character or planet, and view information about the ships they're talking to (their chat, inventory, history of previous places, etc.).\"), mdx(\"span\", {\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"274px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/61f93181fc8a284545b5786d5f562d14/7bcec/actor-app-mobile.jpg\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"218.85714285714286%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/jpeg;base64,/9j/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wgARCAAsABQDASIAAhEBAxEB/8QAGgAAAgMBAQAAAAAAAAAAAAAAAAIEBQYDB//EABUBAQEAAAAAAAAAAAAAAAAAAAAB/9oADAMBAAIQAxAAAAHJiA4gWUaOqyjiIh6JXGMHD//EAB0QAAEFAQADAAAAAAAAAAAAAAEAAgMREgQQFCD/2gAIAQEAAQUC0tLXwI2JwoZFbkRLi3Rrz68Nd3PEOSl//8QAFhEAAwAAAAAAAAAAAAAAAAAAECAh/9oACAEDAQE/ARE//8QAFBEBAAAAAAAAAAAAAAAAAAAAIP/aAAgBAgEBPwFf/8QAHhAAAgEFAAMAAAAAAAAAAAAAAQIRAAMQIDESM5L/2gAIAQEABj8C25QYqPFuYiaCzzX02/mmYW1UiODH/8QAHBABAAICAwEAAAAAAAAAAAAAAQARITEQUWGB/9oACAEBAAE/IVWy/kt5Hbyy7Mppi43f2ChoySiwepot1tgYKMEdvNuhjdI0o0NXkrP/2gAMAwEAAgADAAAAEOAOQqAP/8QAFxEAAwEAAAAAAAAAAAAAAAAAARARAP/aAAgBAwEBPxBAwa5v/8QAGBEAAgMAAAAAAAAAAAAAAAAAEBEAAVH/2gAIAQIBAT8QCyKj/8QAHxABAAICAgIDAAAAAAAAAAAAAQARITEQQVGhYXGR/9oACAEBAAE/EKmAC6mW60rUxhWBWp7Tys8R7WA4UNIVrLGYhmbGtSrUG7JmpcYq7gHiv2dI0ntPPaxeFlpvJshGgOPYjBkbv7nyM//Z')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"actor app mobile\",\n    \"title\": \"actor app mobile\",\n    \"src\": \"/static/61f93181fc8a284545b5786d5f562d14/7bcec/actor-app-mobile.jpg\",\n    \"srcSet\": [\"/static/61f93181fc8a284545b5786d5f562d14/6b97f/actor-app-mobile.jpg 175w\", \"/static/61f93181fc8a284545b5786d5f562d14/7bcec/actor-app-mobile.jpg 274w\"],\n    \"sizes\": \"(max-width: 274px) 100vw, 274px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/5b1fad5df7f013bd9db7e38ba57732a9/2dd9a/actor-app-headless.png\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"49.71428571428571%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAKCAYAAAC0VX7mAAAACXBIWXMAABJ0AAASdAHeZh94AAAA5klEQVQoz82QwU7DMBBE/R1V1Hp3bNdJlJi0lCuCE3eEuPD/vzGVrToyESDEicPII8/qedbm42Xg6xP4/nzg2+OBU+wJa+lEOAK8T4kxBDpVBlWe0x2XlBicY+89x5yJ0N9kuj2464S7Tpk9VKkiRQHgw+XC5XRiCKFkaZ7pQ6AVYTwey5yorjJQIaAEhNlLhuVQhA7gPE2MMdLm1gDPy8JhGMpMH+MKrKfRplHxN1Xg2Pf0zpXcA2X9Oo8GVO9MC2lh9ZTmoextu+Kmna4Nf4B+BV632jb8LfDTt2wAW30L/Kv+P/AKrYgaIliFPTkAAAAASUVORK5CYII=')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"The mobile and headless modes of the actor app give our actors flexibility to use whatever devices they have available\",\n    \"title\": \"The mobile and headless modes of the actor app give our actors flexibility to use whatever devices they have available\",\n    \"src\": \"/static/5b1fad5df7f013bd9db7e38ba57732a9/f847a/actor-app-headless.png\",\n    \"srcSet\": [\"/static/5b1fad5df7f013bd9db7e38ba57732a9/f73d0/actor-app-headless.png 175w\", \"/static/5b1fad5df7f013bd9db7e38ba57732a9/b4640/actor-app-headless.png 350w\", \"/static/5b1fad5df7f013bd9db7e38ba57732a9/f847a/actor-app-headless.png 700w\", \"/static/5b1fad5df7f013bd9db7e38ba57732a9/a94c1/actor-app-headless.png 1050w\", \"/static/5b1fad5df7f013bd9db7e38ba57732a9/b5909/actor-app-headless.png 1400w\", \"/static/5b1fad5df7f013bd9db7e38ba57732a9/2dd9a/actor-app-headless.png 2558w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"The mobile and headless modes of the actor app give our actors flexibility to use whatever devices they have available\"), \"\\n  \"), mdx(\"p\", null, \"The Mobile mode uses a tab-based layout to try and accomplish all the same goals, but since only one tab can be on the screen at a time, it's a bit of a pain to use on its own. We've got Headless mode, which doesn't do any of the audio/video streaming, but shows all the information about the current ship and can run on any laptop. Many actors have opted for using the Mobile mode (since their phone has a good camera) but keeping Headless mode open on an old laptop to make it easier to use the other features.\"), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/319746c592707c3b5ca96e96686dc32e/ea415/ship-in-flight.png\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"65.14285714285714%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAANCAYAAACpUE5eAAAACXBIWXMAAAsTAAALEwEAmpwYAAADS0lEQVQ4y23Qy09cZRiA8ePSlTFFWu6h7XBvKmfOwMyZmXPmXBFB6HBpKZdQQErlIqYQaE0dQFrUgcoUho4tUqBl2rS1baKxjYmJLly4dNHEhStRmAH/iMfMEBcaF798m/d98uYTtneT7Cb2SST32Ukk2dv7i71kgu3EDn8md/kjuUtq5ved/9g9sJ3YS787iQS/vHyJcO/+Fnfj94it3ubGSpSVWJRIZI2F+cdEFh+ztPiU1S+3uLO5zp2NjbT1zU1it2+xHI2md5ZiN7kVXWDm2ocIolMkRXJJSJILt0ekIEtFEE6T+eoFcl7pofi4G5erElF0IooikiRRXlpGhaOE/LwCMg8f4fVDb+ByuxEsyyLFNE1Mw6KmVkcqbyHrtRCnjO85WxDD66rDrjEwTSvNtmwUVUX2+SkpLaOouIScnFzO9fQi6LrOPzTNwLIV3izrwJFxjTbrGwL5V/FJdehmAE1LzWgYhoEsy4hOiWPHHWnZ2bk0t7b+X9DPieIuKg7NU5gZovBwCNXTgm4o/wq63R4cRcUUHj12EMzJpbEpiKClYoaRZugGXl1h0G6jNiuClL2GLy+GRwpi2gcX6pqW/h7Z66O84kQ6mAqngi1nziAYmoaq+FEVBVs3kHUPl5X3Gcv4mbbM32jJ+BX/yXYUtRo9daGupWedTifFpWUUFh4lLy+frCNZ+BQ/gmzatHZ20drRQbVmUG0q9J/+lCvBHzlbvU63+oDe7gk6+7oJmDamGqCrt5f27m4MRaFS9mA2NqDX11MkViJ0DQ4wMz/L2PQUo1Mh5sKX+GR5gtnli4xOD/BuaJwPZsOEPo/QcX6cucUlrm/FmVpZ5cLwGJVehSotQJWmUST7ET6LhFlfizLS9x7PXnzF9v5PxONzTI4PsRKe5OOPQiwsr3DlRpyRoRk2Htzn+t1HXP3iIedHL+OSvaiqC8tScUgehHD0JgNDEwz2DLMVD/Pk+TptTc14DJNz7U10tjbTPzzC5FyMyfFpato6MS8uYo1G6OsfRnRXodf40E2d6pomhCdPn/Ht18958d0PXJqexm3X4vAalAYsPLUN+N9pxl0XxF93Cqs+iK3byGYDstlI0H4bKWBSab2Fy65Fsuv4G6UwKMcEpzYEAAAAAElFTkSuQmCC')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"A group of attendees interacting with a character in the show\",\n    \"title\": \"A group of attendees interacting with a character in the show\",\n    \"src\": \"/static/319746c592707c3b5ca96e96686dc32e/f847a/ship-in-flight.png\",\n    \"srcSet\": [\"/static/319746c592707c3b5ca96e96686dc32e/f73d0/ship-in-flight.png 175w\", \"/static/319746c592707c3b5ca96e96686dc32e/b4640/ship-in-flight.png 350w\", \"/static/319746c592707c3b5ca96e96686dc32e/f847a/ship-in-flight.png 700w\", \"/static/319746c592707c3b5ca96e96686dc32e/ea415/ship-in-flight.png 1000w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"A group of attendees interacting with a character in the show\"), \"\\n  \"), mdx(\"p\", null, \"The Attendee app is about as complex as the actor app, but doesn't have a Headless mode (which is really just a power-user feature for actors). This app has multiple screens corresponding to the different phases of the show:\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Preshow - A lobby screen where attendees can meet the shipmates they'll be flying with\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Intro - A tutorial video\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Freeplay - The bulk of the show, where attendees alternate between interacting with characters and choosing their next destination from the Navigation Screen\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Ending - A livestreamed 3D ending scene\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Ended - This just redirects attendees out of the app to \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"https://shatteredspace.live/program.html\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"a static page with our casts' bios\"), \".\")), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"600px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/47ca2e956ef488f0de918d853fe042d6/f6133/host-app.jpg\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"74.85714285714286%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/jpeg;base64,/9j/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wgARCAAPABQDASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAAAAUEBgf/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/aAAwDAQACEAMQAAABUtFE5NHK0L//xAAZEAADAQEBAAAAAAAAAAAAAAACAwQBADL/2gAIAQEAAQUCFjN0mhw+WodvKqnFc4jQr//EABgRAAIDAAAAAAAAAAAAAAAAAAECEBJR/9oACAEDAQE/AWW7A5H/xAAXEQADAQAAAAAAAAAAAAAAAAABEBIR/9oACAECAQE/AakYv//EAB0QAAEEAwEBAAAAAAAAAAAAAAEAAhESAyFxIkH/2gAIAQEABj8C9PGPjU0DJYz9Q4pfWU1gxw8aLqq13DcaK//EABsQAQADAAMBAAAAAAAAAAAAAAEAESExQXFh/9oACAEBAAE/Ibod0jEXBUbOTfmiqIpwQ0lSmT7nsC5SeBjP/9oADAMBAAIAAwAAABDbL//EABcRAQEBAQAAAAAAAAAAAAAAAAEAURH/2gAIAQMBAT8QMFTGy8b/xAAWEQEBAQAAAAAAAAAAAAAAAAABEQD/2gAIAQIBAT8QAgLgpd//xAAdEAEAAgICAwAAAAAAAAAAAAABABEhQTFhUXGh/9oACAEBAAE/EAeQQqr4MWe5U3xENN53EKMinyJ01yukeF7jErrBxS2dL7uFFV0kULpn/9k=')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"My setup for performing the motion capture at the end of the show\",\n    \"title\": \"My setup for performing the motion capture at the end of the show\",\n    \"src\": \"/static/47ca2e956ef488f0de918d853fe042d6/f6133/host-app.jpg\",\n    \"srcSet\": [\"/static/47ca2e956ef488f0de918d853fe042d6/6b97f/host-app.jpg 175w\", \"/static/47ca2e956ef488f0de918d853fe042d6/86423/host-app.jpg 350w\", \"/static/47ca2e956ef488f0de918d853fe042d6/f6133/host-app.jpg 600w\"],\n    \"sizes\": \"(max-width: 600px) 100vw, 600px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"My setup for performing the motion capture at the end of the show\"), \"\\n  \"), mdx(\"p\", null, \"At the end of the show, I perform a scene as Colonel Panic, the AI commander of the Star Jockeys. In this scene I go around to each ship and review the items they've managed to collect. Then a \\\"resolution\\\" happens that I won't spoil here.\"), mdx(\"p\", null, \"This performance is rendered on my desktop PC (Intel 8700K + RTX 2080TI) with a special version of the Actor app that I've built just for this purpose. The three.js scene here involves at least 7 huge models with MeshPhysicalMaterials and a dozen moving PointLights, so rather than rendering on attendees' computers, I render it on mine and stream it to them using an \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://www.elgato.com/en/gaming/cam-link-4k\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"Elgato Camlink 4K\"), \", ffmpeg and the Janus (explained below) \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://janus.conf.meetecho.com/docs/streaming.html\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"Streaming plugin\"), \".\"), mdx(\"p\", null, \"The motion capture work is done by taking a modified version of Apple's \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://developer.apple.com/documentation/arkit/tracking_and_visualizing_faces\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"TrackingAndVisualizingFaces\"), \" example from the ARKit docs and having it send each frame of mocap data over WebSockets to the server, which then forwards them to the Host app. It's amazing that this works with sub-100ms latency, but it does.\"), mdx(\"p\", null, \"I'm planning on open-sourcing all of this when the show is done, but I'd want to purge the git history in case there are any secrets lying around, and I'd warn any interested spelunkers that it will be more of an \\\"interesting artifact to look at\\\" than \\\"a thing you could easily get running locally\\\".\"), mdx(\"h2\", {\n    \"id\": \"webrtc-and-janus\"\n  }, mdx(\"a\", {\n    parentName: \"h2\",\n    \"aria-hidden\": \"true\",\n    \"tabIndex\": -1,\n    \"href\": \"#webrtc-and-janus\"\n  }, mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"icon icon-link\"\n  })), \"WebRTC and Janus\"), mdx(\"p\", null, \"As of this writing, the only plugin-free way to do real-time video conferencing in the browser is through WebRTC, a web standard for RealTime Communication that can handle streaming audio, video and even packets of arbitrary data. In tutorials, WebRTC is often demoed as a Peer-to-Peer communication tool, and it certainly \", mdx(\"em\", {\n    parentName: \"p\"\n  }, \"can\"), \" do that. But if you have 5 attendees in a room, then each one has to broadcast 4 audio/video streams (one for each other attendee) and receive 4 more streams. This can be tough on people who are having connectivity issues or are using mobile networks.\"), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/9ad7bc3ad96c8e36d8e958d6511e131d/38aa5/mesh-vs-sfu.png\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"55.99999999999999%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAALCAIAAADwazoUAAAACXBIWXMAAAsTAAALEwEAmpwYAAACCElEQVQoz02Q2W7aUBCGeT3eoWpvW+W+UqW+Qi8iiARJ2kZV1IuqvSAkxGqdiNS4KQQKYrENXjBe431fcGyfykCijI5GZz7Nr5l/Sp0hsnm//4xQdIQiu/KRDDtPBNkQpPjsSAk8RgIeUpCDZxGDJAPZcxKBdf6sp5QBEDID/vwAa+4z6DfzH5SFdkD1Vs0K0apIvTNvDKex5+HIqlldXB6I3XN3cpVGbprnxeRkBssf99jPb+VWTftZy30jGUPi4RvuyzsZqhvwUR658d0Pvv5aOH0vXNas9gkI7WJyIWYHBlQVoCMBbSi3DRA5D0xfa1X41qGINtS/Z2AdJIuOerEvQMd8p6HfXYC1vxMDAMIoxKbtLLJAXljKN1nRJZ4dbMstEWRakWZPDYVYYnr48HrJ4JrETRBYIqcsiWgKy0qabptUH7EUgcbhe5HBiBEnzOa3sG9rO7Fmmcsl5XixrluKKMdREIZWHCefTr6qhuurYuga0dozFbpa/SCroiUu48DZiU2+S4x7mhOpVjhC25rEZkmAD747ns0xNDm8KXw595PuqRf4i3FfJAsvWZZt1lamusluzbs8FVjFSpanFLcM/EATAQBpGtt+wV1dobGhZhhRFJVIZlH9Vb/GbhiOJpY4JbELniJYghZYjMHnHElJLL7E5qsFyTEkT12h7RcvX5XL5dpx7T/qrD7YwyT2WwAAAABJRU5ErkJggg==')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"Diagram of the different WebRTC topologies from the [WebRTC Glossary](https://webrtcglossary.com/sfu/)\",\n    \"title\": \"Diagram of the different WebRTC topologies from the [WebRTC Glossary](https://webrtcglossary.com/sfu/)\",\n    \"src\": \"/static/9ad7bc3ad96c8e36d8e958d6511e131d/f847a/mesh-vs-sfu.png\",\n    \"srcSet\": [\"/static/9ad7bc3ad96c8e36d8e958d6511e131d/f73d0/mesh-vs-sfu.png 175w\", \"/static/9ad7bc3ad96c8e36d8e958d6511e131d/b4640/mesh-vs-sfu.png 350w\", \"/static/9ad7bc3ad96c8e36d8e958d6511e131d/f847a/mesh-vs-sfu.png 700w\", \"/static/9ad7bc3ad96c8e36d8e958d6511e131d/a94c1/mesh-vs-sfu.png 1050w\", \"/static/9ad7bc3ad96c8e36d8e958d6511e131d/38aa5/mesh-vs-sfu.png 1140w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"Diagram of the different WebRTC topologies from the [WebRTC Glossary](https://webrtcglossary.com/sfu/)\"), \"\\n  \"), mdx(\"p\", null, \"Generally if you want to build a web-based video conferencing product, you'll want servers in the cloud that can multiplex these streams for you. That way, each attendee only has to send a single copy of their stream (they still have to receive the other 4, this can't be helped). The industry terms for these two approaches are \\\"Mesh\\\" vs. \\\"Selective Forwarding Unit\\\" (often abbreviated SFU).\"), mdx(\"p\", null, \"There are a number of open source servers that can do this, and the one we used for Shattered Space is \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://janus.conf.meetecho.com/\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"Janus\"), \" by the team at \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://www.meetecho.com/en/\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"meetEcho\"), \". I was familiar with it from other projects, its maintainers have put in years of consistent work (\", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://github.com/meetecho/janus-gateway/graphs/contributors\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"seriously \", mdx(\"strong\", {\n    parentName: \"a\"\n  }, \"look\"), \" at this contributor graph\"), \"), and since it's written in C it's blazing fast and almost never hits double-digit CPU usage on a \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"t3a.nano\"), \" on EC2.\"), mdx(\"p\", null, \"Janus not only multiplexes streams for you, it also has APIs that assist with a lot of the troublesome signalling and NAT-punching required to start a WebRTC session. All this is wrapped up in a client-side JS library with lots of \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://janus.conf.meetecho.com/echotest.html\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"good\"), \" \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://janus.conf.meetecho.com/videoroomtest.html\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"useful\"), \" \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://janus.conf.meetecho.com/vp9svctest.html\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"examples\"), \" that can serve as starting points for projects (if you're not getting it yet I really like this thing).\"), mdx(\"p\", null, \"The two thorny issues I ran into revolved around the topics of the next two sections:\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Incorporating WebRTC into a Redux app\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Acquiring Media Devices\")), mdx(\"p\", null, \"But other than those, Janus was great. I've never needed to randomly restart the servers because of memory leaks, the plugin system gives you tons of options for features, you can whip up a Docker image and deploy it in minutes. Seriously, check this thing out.\"), mdx(\"h2\", {\n    \"id\": \"webrtc-and-redux\"\n  }, mdx(\"a\", {\n    parentName: \"h2\",\n    \"aria-hidden\": \"true\",\n    \"tabIndex\": -1,\n    \"href\": \"#webrtc-and-redux\"\n  }, mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"icon icon-link\"\n  })), \"WebRTC and Redux\"), mdx(\"p\", null, \"Creating and maintaining a WebRTC PeerConnection is one of the most side-effecty things you can do on the front end. You've got:\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"multiple kinds of errors, each with different error handlers (whose errors need to be handled in different ways based on their cause).\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"multiple kinds of other events which create more stateful side-effecty stuff (like when a user joins the room, leaves the room, or toggles their mute switch)\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"a lot of loose ends that need to be \\\"cleaned up\\\" or disposed of, like connection objects or streams from a local media device (we'll get to those in a moment)\")), mdx(\"p\", null, \"I lack any notion of respect for my time, so naturally, I chose to build this app in Redux.\"), mdx(\"p\", null, \"Joking aside, I really like Redux and the way that it lets me structure applications. I've used it for years (usually via \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://www.npmjs.com/package/generator-react-webpack-redux\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"generator-react-webpack-redux\"), \") and have found it pretty adaptable to a number of projects. Nonetheless, one of its biggest weaknesses is around handling side effects. Usually I use the \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://www.npmjs.com/package/redux-thunk\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"redux-thunk\")), \" package to handle asynchronous or side-effecty processes, but for this one I decided to take a crack at \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://redux-saga.js.org/\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"redux-saga\")), \". This was a good choice.\"), mdx(\"p\", null, \"Redux Saga takes a bit of time to get your head around (unlike the rest of the JS world, it hopped off the async/await train and onto the generators one). But once you do you'll find that it is capable of creating abstractions that Promises and async/await could only dream of, like \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://redux-saga.js.org/docs/advanced/Channels.html#using-the-eventchannel-factory-to-connect-to-external-events\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"event channels\"), \", \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://redux-saga.js.org/docs/advanced/TaskCancellation.html\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"cancellable async tasks\"), \" and even \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://redux-saga.js.org/docs/advanced/ForkModel.html\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"\\\"forked\\\" processes\"), \" within the single threaded event loop. It's great for times like this where you need to spin something up in response to an action, inhale any actions it puts out, and clean up when you're done with it. I intend to write a more detailed article about using Redux Saga in WebRTC apps, because they're a pairing that was made for each other.\"), mdx(\"h2\", {\n    \"id\": \"acquiring-media-devices\"\n  }, mdx(\"a\", {\n    parentName: \"h2\",\n    \"aria-hidden\": \"true\",\n    \"tabIndex\": -1,\n    \"href\": \"#acquiring-media-devices\"\n  }, mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"icon icon-link\"\n  })), \"Acquiring Media Devices\"), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/9e25b6a65e937203c532a413710b5c4a/392b1/media-permission-dialog.png\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"63.42857142857142%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAANCAYAAACpUE5eAAAACXBIWXMAABJ0AAASdAHeZh94AAABu0lEQVQ4y62SS2/aQBDHV0IpEg9j8xCIhwQ2KhGpk9js2m7a0qgP0Ru3cCBwiM056odMDlzSVknd0g/At+BfzRYsh7qqVHWln2b2MbP/mV32avgW795/wPD1G3Duod838bTXlxjdQ+hG7xfdLTHf6PbQ65sQ7guY5imemTbY4ZGN6+uPWK1+4ObmFsvlEnd3n3B//4DLyxlOjo/gOgKWLWBZHAObwzZPMTBP4D33MOAuzs+HODt7Cc5dMMYYgkUAGpvNBvExubjAk4MDlMtlaJoWQ92iQVVVFBRFWk1VwVKpFCaTCdbrNcIwRPjtOz5/ecDXcIXRaIR0Oo1isSgDEtleIhNqGhg5pVIJ1WpVUqvVImidDlHCuML9+aOEkexCAYqiIJPJIJvNSnK5XCK0l8/noyQ7tZFCcihhvV6H67oQQsARApwLOI4joXWCfM/zYFmWrILifiuZHLqx3W7D930EfoDFIoB/5WM+n2M6nUbMZjPJeDxGq9WSVSUmJEuv2el0oBsGdF2XUBApbzQa0u5oNpvyfDz+UQ93lpTG+0VzUpFEYg/3X4teMM7+/t9gf/xf/wirVCr4n/wEnl58XsRf9x0AAAAASUVORK5CYII=')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"We're a good app, honest!\",\n    \"title\": \"We're a good app, honest!\",\n    \"src\": \"/static/9e25b6a65e937203c532a413710b5c4a/f847a/media-permission-dialog.png\",\n    \"srcSet\": [\"/static/9e25b6a65e937203c532a413710b5c4a/f73d0/media-permission-dialog.png 175w\", \"/static/9e25b6a65e937203c532a413710b5c4a/b4640/media-permission-dialog.png 350w\", \"/static/9e25b6a65e937203c532a413710b5c4a/f847a/media-permission-dialog.png 700w\", \"/static/9e25b6a65e937203c532a413710b5c4a/a94c1/media-permission-dialog.png 1050w\", \"/static/9e25b6a65e937203c532a413710b5c4a/b5909/media-permission-dialog.png 1400w\", \"/static/9e25b6a65e937203c532a413710b5c4a/392b1/media-permission-dialog.png 1472w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"We're a good app, honest!\"), \"\\n  \"), mdx(\"p\", null, \"Despite being the topic of \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://www.html5rocks.com/en/tutorials/getusermedia/intro/\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"numerous\"), \" \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://flaviocopes.com/getusermedia/\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"introductory\"), \" \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://www.sitepoint.com/introduction-getusermedia-api/\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"WebRTC\"), \" \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://www.html5rocks.com/en/tutorials/webrtc/basics/\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"tutorials\"), \", the hardest part of this app was just getting the user's camera and microphone in the first place. Sure, 80% of the time it's as simple as going \", mdx(\"code\", null, \"await navigator.mediaDevices.getUserMedia({ audio: true, video: true })\"), \", and then if the user deems your application worthy you receive a \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://developer.mozilla.org/en-US/docs/Web/API/MediaStream\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"MediaStream\")), \" that you can view in a \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"<video>\"), \" element, send over an \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"RTCPeerConnection\")), \", etc. And while that's basically true, if you're building a real production app there are a number of other concerns you need to account for.\"), mdx(\"p\", null, \"First of all, if the user denies your request for camera, microphone or both, you need a way to gracefully fallback without reducing too much functionality. In our case, we politely ask users to allow us to use their microphone and camera and let them know that they can turn them on or off at any time in the show. But if they don't trust us and want to do the whole show via text chat (while viewing and hearing their shipmates and the actors) we make sure to let them do that too. This makes things complicated (in part because we try to detect if the user has allowed camera-but-not-microphone and vice-versa) but we're only scratching the surface.\"), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/5a56081c7a0ec1f395363f5ca9bcc667/f3a83/device-selector.jpg\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"59.42857142857143%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/jpeg;base64,/9j/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wgARCAAMABQDASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABQAE/8QAFQEBAQAAAAAAAAAAAAAAAAAAAQD/2gAMAwEAAhADEAAAAQXB8UMw0P8A/8QAGhAAAgMBAQAAAAAAAAAAAAAAAgMBBBMAEv/aAAgBAQABBQKvnMZ1fJBX6moWDDzgWvJxf//EABQRAQAAAAAAAAAAAAAAAAAAABD/2gAIAQMBAT8BP//EABQRAQAAAAAAAAAAAAAAAAAAABD/2gAIAQIBAT8BP//EAB0QAQACAgIDAAAAAAAAAAAAAAEAEQIhAxITIkH/2gAIAQEABj8Cy8lL8sgdOPvmaamL0NlzkW9StQUxKK9Sp//EABkQAAMBAQEAAAAAAAAAAAAAAAABETEhgf/aAAgBAQABPyF5KxaCnXRueeskUmeBodTIJC4xzoxO8EB//9oADAMBAAIAAwAAABC7L//EABcRAAMBAAAAAAAAAAAAAAAAAAABETH/2gAIAQMBAT8Qe0Th/8QAFhEBAQEAAAAAAAAAAAAAAAAAAQAh/9oACAECAQE/EAJNy//EAB0QAQEAAgMAAwAAAAAAAAAAAAERACExQWFxscH/2gAIAQEAAT8QMHEHA3ZOOsRmBNNB3SWkk7MGU4h2aP1gsAwRGi7p5i9I2TR8Pv4YQWJqAu0vO+c//9k=')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"If your user's device setup is a mess like mine, you'll need to give them the tools to dig themselves out.\",\n    \"title\": \"If your user's device setup is a mess like mine, you'll need to give them the tools to dig themselves out.\",\n    \"src\": \"/static/5a56081c7a0ec1f395363f5ca9bcc667/43804/device-selector.jpg\",\n    \"srcSet\": [\"/static/5a56081c7a0ec1f395363f5ca9bcc667/6b97f/device-selector.jpg 175w\", \"/static/5a56081c7a0ec1f395363f5ca9bcc667/86423/device-selector.jpg 350w\", \"/static/5a56081c7a0ec1f395363f5ca9bcc667/43804/device-selector.jpg 700w\", \"/static/5a56081c7a0ec1f395363f5ca9bcc667/518fe/device-selector.jpg 1050w\", \"/static/5a56081c7a0ec1f395363f5ca9bcc667/e3000/device-selector.jpg 1400w\", \"/static/5a56081c7a0ec1f395363f5ca9bcc667/f3a83/device-selector.jpg 1602w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"If your user's device setup is a mess like mine, you'll need to give them the tools to dig themselves out.\"), \"\\n  \"), mdx(\"p\", null, \"The default video and audio sources available to \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"getUserMedia\"), \" may not be the ones the user intends to use. For instance, a user might have:\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"a good USB microphone, and a Bluetooth headset with a bad microphone (but which was set up more recently and is the browser's default mic).\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"a good USB webcam mounted on their monitor, and a built-in webcam on their laptop which is running in \\\"clamshell mode\\\" (but which the browser chooses as the default)\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"a good USB webcam that \\\"hangs\\\" when you try to access it... because it is being used by a webcam filter app like SplitCam which presents its own pseudo-video device you should be using instead\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"a capture card or similar device which presents as a webcam but actually just shows what's on the user's monitor.\")), mdx(\"p\", null, \"So naturally you'll want to make a selector that allows users to choose which device to use (or to use no device at all). For that you'll need \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"navigator.mediaDevices.enumerateDevices()\")), \", which returns a complete list of the user's available devices, their user-readable labels, and the deviceIds you can use to request their feeds, all without prompting the user for permisson.\"), mdx(\"p\", null, \"\\\"Wait what?\\\"\"), mdx(\"p\", null, \"Well, if the user hasn't granted permission (or hasn't been asked) you'll get back a list containing only the default audio and video devices, their groupIds (but not deviceIds) and no labels. This is to prevent fingerprinting, and is all well and good because you can figure out if you've prompted the user for mic/camera access by calling \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://www.youtube.com/watch?v=dQw4w9WgXcQ\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"navigator.mediaDevices...uhoh there's no way to do this\")), \".\"), mdx(\"p\", null, \"So, as our app is booting up, our logic for getting a list of available devices looks like this:\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-js\"\n  }, \"  try {\\n    // After this we'll know for SURE whether we've asked permission\\n    let stream = await navigator.mediaDevices.getUserMedia({\\n      video: true,\\n      audio: true\\n    });\\n    // But these might not be the devices we actually want,\\n    // so dump this stream and wait for the user to select a device\\n    stream.getTracks().map(track => {\\n      stream.removeTrack(track);\\n      track.stop();\\n    });\\n\\n    // If we've made it this far, we know we'll get \\\"the good list\\\"\\n    let devices = await navigator.mediaDevices.enumerateDevices();\\n  } catch(e) {\\n    console.log(`Error getting input devices: ${e.message}`);\\n  }\\n\")), mdx(\"p\", null, \"That's right: We acquire a stream (and hope the default media devices are available and don't hang) and then if we get one (meaning we've got permission and can get the real device list) we immediately dump the stream and then get a list of the devices. Of course, it's more complicated--if we fail at getting the stream we test trying to get the camera and microphone individually and then use those results (after dumping the streams) to figure out what permissions the user has given us--but like the Saga/WebRTC integration, this topic probably also deserves it's own article.\"), mdx(\"p\", null, \"But I'm going to go on a bit longer: You will need to become \", mdx(\"em\", {\n    parentName: \"p\"\n  }, \"intimately\"), \" familiar with each browser+OS combo's unwritten rules around device access and occasional \\\"bad habits\\\". Things like:\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"On iOS: only one application can have camera access at a time, so if the user just got off a Zoom call but forgot to close it, you'll get an error when trying to pull a media stream even if the user has previously given your app permission\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"On iOS: the Safari App, Safari standalone PWAs, and SafariViewControllers can all use WebRTC. UIWebViews and WKWebViews cannot, and \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"navigator.mediaDevices\"), \" is actually undefined. This means Chrome for iOS does not support WebRTC, but if you open a link from Slack or a modern email app, that WebView actually \", mdx(\"em\", {\n    parentName: \"li\"\n  }, \"can\"), \" use WebRTC. This is for security reasons, and is an improvement over the old days of \\\"the Safari App or bust\\\", but I do wish Apple and Google could figure out a way for this to work in Chrome for iOS.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"In general: You'll often come across unhelpful errors like \\\"unable to find device\\\" or \\\"video source failed to start\\\". These could mean almost anything, but I've often found that switching to \\\"no input\\\" for a few seconds and back to the device again can fix it, as can unplugging and replugging the device if it's over USB. This is a hard one to diagnose remotely, and is a real stinker.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Regarding webcams in general: Bear in mind that a webcam generally starts recording in a particular resolution and framerate, and if another app or page requests that same camera with a different resolution or framerate, it'll be like \\\"I'm already recording, you're gonna get what you're gonna get\\\". Webcams generally can't take their sensor input and transform it into different forms for different apps. I recommend getting 2 cheap webcams from different manufacturers to make testing easier and coverage better.\")), mdx(\"p\", null, \"And finally I will note a few more things that developers working with media devices need to be aware of:\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"If you're going to implement this sort of device switching (and it's pretty mandatory UX for a consumer video app) it means you can't rely on libraries like Janus.js to handle this stuff for you. You'll need to acquire the \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"MediaStream\"), \" yourself and hand it to your WebRTC library, rather than just telling the library \\\"use video but not audio for this one\\\".\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"When you switch devices, it's not as simple as swapping a MediaTrack in your MediaStream with a MediaTrack from your new MediaStream, as doing so will cause the old MediaTrack to fire an \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"ended\"), \" event which will kill your RTCPeerConnection. Since there isn't a (working cross-platform as of 6/2020) \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"MediaStream.replaceTrack()\"), \" method, you pretty much have to teardown all your WebRTC stuff and reconnect any time something about the media device changes. Making this operation easy to perform and leak-proof will be important, because you will need to do it a lot.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"One workaround for the above is to pass all audio through the Web Audio API and all video through a canvas element and use \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \".captureStream()\"), \" to create a video track from the canvas element that never \\\"ends\\\". But unless the user has a fairly beefy computer (and isn't in Safari) this won't work well.\")), mdx(\"h2\", {\n    \"id\": \"threejs-and-offscreencanvas\"\n  }, mdx(\"a\", {\n    parentName: \"h2\",\n    \"aria-hidden\": \"true\",\n    \"tabIndex\": -1,\n    \"href\": \"#threejs-and-offscreencanvas\"\n  }, mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"icon icon-link\"\n  })), \"three.js and OffscreenCanvas\"), mdx(\"video\", {\n    style: {\n      \"maxWidth\": \"100%\"\n    },\n    src: \"https://cdn.chrisuehlinger.com/show-videos/planet-selection.mp4\",\n    muted: true,\n    autoPlay: true,\n    loop: true\n  }), mdx(\"p\", null, \"After viewing a short intro/tutorial video, the attendees find themselves on the Navigation Screen. This is where they choose which planet, space station, or other destination they want to go to next. Any member of the group can select a destination for them all to look at, and one member (randomly chosen each time) gets to select which planet to go to. This consists of two main components:\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"A list UI for making selections and a button for confirming the selection.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"A canvas showing a three.js-rendered view of the destination\")), mdx(\"p\", null, \"Like a lot of the situations in this post: I'd used three.js before, but usually in cases where the 3D piece basically \", mdx(\"em\", {\n    parentName: \"p\"\n  }, \"was\"), \" the page. In this instance, I was building a 3D scene \", mdx(\"em\", {\n    parentName: \"p\"\n  }, \"into\"), \" an app that had a lot of other functionality. So let's say a user is on an underpowered device but is running Chrome, and they're rendering the scene at 1 FPS or worse. We want to make sure they can still click the buttons in the UI and have them respond in a timely manner.\"), mdx(\"p\", null, \"So how do you solve a problem you created using advanced web tech? Obviously, by throwing even more web tech at it.\"), mdx(\"p\", null, mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"OffscreenCanvas\")), \" is a Web API that allows you to perform costly rendering work in a Web Worker, separate from the main UI thread. Now granted, it doesn't completely solve the problem (if your rendering tanks to 25FPS, it'll take the main thread's FPS down about as far) but it does prevent two big problems:\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Errors thrown in the Web Worker are contained and won't impact the main thread (just kill the 3D animation).\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Sometimes the three.js scene totally locks up its thread (when compiling shaders or loading a complicated model) and in those times, the UI stays responsive.\")), mdx(\"p\", null, \"But it also brings new problems of its own, so watch out for these:\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Safari doesn't support \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"OffscreenCanvas\"), \", so you'll need a polyfill that will allow you to run all your code on the main thread if need be. I recommend \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"https://github.com/ai/offscreen-canvas\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"offscreen-canvas\")), \" as a good starting point, but I adapted it for my own uses to better support having multiple canvas workers who can be sent new canvases as need be.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"If you're using webpack, you'll need to make a new entrypoint for your worker code and you'll want to make a \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"<link rel=\\\"preload\\\">\"), \" tag so your polyfill can find the name of your worker script and load it on the appropriate thread. Check out \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"https://www.npmjs.com/package/preload-webpack-plugin\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"preload-webpack-plugin\")), \".\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"If you're using three.js, you'll need to make sure it never tries to create/modify DOM elements when running in a WebWorker. You'll need to pass a \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"false\"), \" flag into \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"renderer.setSize()\"), \" so it doesn't try and modify the canvas's style, and you'll need make your own versions of loaders like \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"https://github.com/mrdoob/three.js/blob/master/src/loaders/TextureLoader.js\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"TextureLoader\")), \" and \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"https://github.com/mrdoob/three.js/blob/master/examples/jsm/loaders/FBXLoader.js\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"FBXLoader\")), \" using \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"https://three.js.org/docs/index.html#api/en/loaders/ImageBitmapLoader\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"ImageBitmapLoader\")), \" under the hood instead of \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"https://three.js.org/docs/index.html#api/en/loaders/ImageLoader\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"ImageLoader\")), \". This is mostly a drop-in replacement, but make sure to read up on how \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"ImageBitmapLoader\"), \" differs from \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"ImageLoader\"), \" (on things like \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \".flipY\"), \") and adapt accordingly.\")), mdx(\"h2\", {\n    \"id\": \"threejs-and-memory-management\"\n  }, mdx(\"a\", {\n    parentName: \"h2\",\n    \"aria-hidden\": \"true\",\n    \"tabIndex\": -1,\n    \"href\": \"#threejs-and-memory-management\"\n  }, mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"icon icon-link\"\n  })), \"three.js and Memory Management\"), mdx(\"p\", null, \"The other new problem was memory management: Simply letting three.js objects fall out of scope isn't enough to truly garbage collect them, because three.js keeps a registry of data in the background to improve performance (given how WebGL works, this is understandable).\"), mdx(\"p\", null, \"Classes that require \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://three.js.org/docs/index.html#manual/en/introduction/How-to-dispose-of-objects\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"disposal\"), \" have a \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \".dispose()\"), \" method. I basically mirrored this in my code: I created a class for each \\\"thing\\\" in my scene, with a constructor that loaded all the models and textures (adding them to a \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"this.disposables\"), \" array as it went), a \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \".update()\"), \" method for any animations, and a \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \".dispose()\"), \" method that would call \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \".dispose()\"), \" on all of the disposables and handle any other necessary cleanup.\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-js\"\n  }, \"export default class TLW {\\n  constructor(globals, options) {\\n    this.globals = globals;\\n    this.disposables = [];\\n\\n    (new FBXLoader(globals.loadingManager))\\n      .setPath(`${config.ASSET_PATH}/planets/TLW/`)\\n      .load( `fighter_low.fbx`, ( object ) => {\\n        object.traverse(( child ) => {\\n          if ( child.isMesh ) {\\n            // Add each loaded geometry to the disposables list\\n            this.disposables.push(child.geometry);\\n            // Don't forget their materials\\n            this.disposables.push(child.material);\\n            // Or their textures\\n            this.disposables.push(child.material.map);\\n          }\\n        } );\\n\\n        this.group.add( object );\\n      });\\n\\n    this.group = new THREE.Group();\\n    this.group.position.set(...options.position);\\n    this.options = options;\\n  }\\n\\n  dispose() {\\n    this.globals = null;\\n    this.disposables.map(asset => {\\n      // Wrap this in a try/catch and announce in the console if we\\n      // try to dispose of something that doesn't need to be\\n      try {\\n        asset.dispose();\\n      } catch (e){\\n        console.error('DISPOSAL ERROR', asset);\\n        console.error(e);\\n      }\\n    });\\n  }\\n}\\n\")), mdx(\"p\", null, mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"A simplified version of the code used to load \\u023E\\u2C60\\u1E98, a ship stranded in space\")), mdx(\"p\", null, \"I'd estimate this is about 60-70% of the effort required to perfectly memory manage a complicated scene, and lets about 5-10% of leaks through (Don't ask me for my data I have none). The biggest loophole left is that if I instantiate one of my classes, it starts loading some models/textures and then gets destroyed before those assets load, they will load, have nowhere to go, and then leak. In practice this didn't happen often, and crashes due to memory usage appear fairly rare, and so I'm satisfied with this approach for this project.\"), mdx(\"p\", null, \"I later obviated the need for most of this by persisting my three.js scene even when it's not on screen and just rebuilding/destroying the renderer whenever React mounted/unmounted the NavigationScreen component. This made loading the scene (after the initial load) near instantaneous, and meant there weren't as many possible places for memory leaks.\"), mdx(\"p\", null, \"And finally, I'll note with a bit of shame that I cut the three.js entirely on mobile devices (crudely defined as a device with a dimension under 500px) since at that point, the list UI took up the entire screen.\"), mdx(\"h2\", {\n    \"id\": \"detecting-and-solving-issues-remotely\"\n  }, mdx(\"a\", {\n    parentName: \"h2\",\n    \"aria-hidden\": \"true\",\n    \"tabIndex\": -1,\n    \"href\": \"#detecting-and-solving-issues-remotely\"\n  }, mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"icon icon-link\"\n  })), \"Detecting and Solving Issues Remotely\"), mdx(\"figure\", {\n    \"className\": \"gatsby-resp-image-figure\",\n    \"style\": {}\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-wrapper\",\n    \"style\": {\n      \"position\": \"relative\",\n      \"display\": \"block\",\n      \"marginLeft\": \"auto\",\n      \"marginRight\": \"auto\",\n      \"maxWidth\": \"700px\"\n    }\n  }, \"\\n      \", mdx(\"a\", {\n    parentName: \"span\",\n    \"className\": \"gatsby-resp-image-link\",\n    \"href\": \"/static/be08f4b9c841dbaf06ddcd6665204673/38be8/error-log.png\",\n    \"style\": {\n      \"display\": \"block\"\n    },\n    \"target\": \"_blank\",\n    \"rel\": \"noopener\"\n  }, \"\\n    \", mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-background-image\",\n    \"style\": {\n      \"paddingBottom\": \"49.71428571428571%\",\n      \"position\": \"relative\",\n      \"bottom\": \"0\",\n      \"left\": \"0\",\n      \"backgroundImage\": \"url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAKCAYAAAC0VX7mAAAACXBIWXMAABJ0AAASdAHeZh94AAABb0lEQVQoz5WRzW6DMBCEeZ8GiFQaqkCD+TUEQxJIwVRRn6lKqMShPfRJp1qrUJJbDiOL3eXz7FjjdY+8/UL1/gNeD0hfB6THAdlxQCzO8PMzWEH6BD9+Y5P32OSXP/XwRI+X7QVudsaaf0BbLB5QlgXatoHnbcAYA2MewjDArhDYlQWerEc4zhpvnUSWcXAeI00TRFEIn3nY73cIAx+GvoBmmgbiOEae5/B9H0EQqDNNU3RdB9l1sG0bq5UNIQRyIdRZFAWSJAFjPk6nE7bbLXRdh2YYhmqUZamaI5BqbduibaUC2vazmqGLSQS9AmY3wKqqEIahgpHINQGlHB2ukGUZOOdKtEEURWqW/qV5YikgfdR1PQ2MwKZpFJSAlmVN7m9FuTuOA9M0r1eeZ0g1KeXkkIC0AfXnolmqu66L5XL5D6RM5sB7HFKdgJPD8ZXJ+hxIDrs7HCog2STY4XCYMiTw6JA0d0ivepsf1ccMfwHF/muX3F/TBQAAAABJRU5ErkJggg==')\",\n      \"backgroundSize\": \"cover\",\n      \"display\": \"block\"\n    }\n  }), \"\\n  \", mdx(\"img\", {\n    parentName: \"a\",\n    \"className\": \"gatsby-resp-image-image\",\n    \"alt\": \"I generally keep this real-time error log visible somewhere around me during the whole show\",\n    \"title\": \"I generally keep this real-time error log visible somewhere around me during the whole show\",\n    \"src\": \"/static/be08f4b9c841dbaf06ddcd6665204673/f847a/error-log.png\",\n    \"srcSet\": [\"/static/be08f4b9c841dbaf06ddcd6665204673/f73d0/error-log.png 175w\", \"/static/be08f4b9c841dbaf06ddcd6665204673/b4640/error-log.png 350w\", \"/static/be08f4b9c841dbaf06ddcd6665204673/f847a/error-log.png 700w\", \"/static/be08f4b9c841dbaf06ddcd6665204673/a94c1/error-log.png 1050w\", \"/static/be08f4b9c841dbaf06ddcd6665204673/b5909/error-log.png 1400w\", \"/static/be08f4b9c841dbaf06ddcd6665204673/38be8/error-log.png 2525w\"],\n    \"sizes\": \"(max-width: 700px) 100vw, 700px\",\n    \"style\": {\n      \"width\": \"100%\",\n      \"height\": \"100%\",\n      \"margin\": \"0\",\n      \"verticalAlign\": \"middle\",\n      \"position\": \"absolute\",\n      \"top\": \"0\",\n      \"left\": \"0\"\n    },\n    \"loading\": \"lazy\",\n    \"decoding\": \"async\"\n  }), \"\\n  \"), \"\\n    \"), \"\\n    \", mdx(\"figcaption\", {\n    parentName: \"figure\",\n    \"className\": \"gatsby-resp-image-figcaption\"\n  }, \"I generally keep this real-time error log visible somewhere around me during the whole show\"), \"\\n  \"), mdx(\"p\", null, \"In general, we've seen that attendees log on 5-10 minutes before their show is scheduled to start. That means if they're having issues, we have 5-10 minutes to solve them.\"), mdx(\"p\", null, \"This requires a multi-faceted approach with interventions at many points in the process:\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Our marketing material and login link email recommend using Google Chrome (though it is not strictly required) but also that if you're on iOS you must use Safari (no other iOS browsers can work with WebRTC).\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Our tech support team monitors our company email, which is used to send people their login links. This is the main way people reach out to us when they have problems, in part because:\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Any unrecoverable errors in the app are caught by a \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"https://reactjs.org/docs/error-boundaries.html\",\n    \"target\": \"_blank\",\n    \"rel\": \"nofollow\"\n  }, \"React Error Boundary\"), \" which then renders an error page with the error's message and a mailto link to our email so they can reach out to us directly.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"At show time, if we have attendees who haven't logged on, our tech support team reaches out via email to see if they're having login issues.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Assuming users haven't turned our telemetry off, we will get immediate notification of any errors (recoverable or not) thrown by the actor and attendee apps. The tech support folks can use those to figure out what troubleshooting steps to email the user (often as simple as \\\"refresh\\\", \\\"update your browser\\\" or \\\"turn your mic off and then on\\\"). These error logs are not persisted to the database (they're sent straight to the Admin app and disappear on refresh).\")), mdx(\"p\", null, \"I've got to give shout outs to our stage manager Liz Richardson, marketing director Donna Ibale and marketing intern Mark Uehlinger (my brother) who act as our crack tech support team, and do a lot of the stuff I just described. Solving technical issues in under 5 minutes for remote users who can only communicate via email is no small task. To the extent that the audience gets in and has a good time, it's in large part thanks to their diligent effort and quick action.\"), mdx(\"h2\", {\n    \"id\": \"come-see-shattered-space\"\n  }, mdx(\"a\", {\n    parentName: \"h2\",\n    \"aria-hidden\": \"true\",\n    \"tabIndex\": -1,\n    \"href\": \"#come-see-shattered-space\"\n  }, mdx(\"span\", {\n    parentName: \"a\",\n    \"className\": \"icon icon-link\"\n  })), \"Come See Shattered Space!\"), mdx(\"p\", null, \"As of writing we have two weekends left of this show. One thing I may have forgotten to mention is that this show is \", mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"absolutely stacked\"), \": we've got a bunch of the best actors in the Baltimore theatre scene and even actors from New York and Boston (since we're not bound by location). You can attend this show from anywhere in the world from the comfort of your living room!\"), mdx(\"h3\", {\n    style: {\n      \"textAlign\": \"center\",\n      \"fontWeight\": \"bold\",\n      \"margin\": \"0 auto 15px\"\n    }\n  }, mdx(\"a\", {\n    href: \"https://shatteredspace.live\",\n    target: \"_blank\"\n  }, \"Tickets are available HERE!\")), mdx(ShatteredCards, {\n    mdxType: \"ShatteredCards\"\n  }));\n}\n;\nMDXContent.isMDXComponent = true;","frontmatter":{"title":"Unshattering the Audience: Taking Theatre Online with WebGL and WebRTC","date":"2020-06-16T00:00:00.000Z"}}},"pageContext":{"id":"b3d872f6-2da5-577a-8c6d-a51752a9cd0f"}},
    "staticQueryHashes": ["1831480344","3649515864"]}