diff --git a/scripts/build_local_reticulum.sh b/scripts/build_local_reticulum.sh
index f883958f8f18e028a32916f50c6af38002938893..3f5e3a00136a84adf99b68926ee0dd1b17a09af3 100755
--- a/scripts/build_local_reticulum.sh
+++ b/scripts/build_local_reticulum.sh
@@ -4,4 +4,4 @@ if [ ! -e ../reticulum ]; then
   echo "This script assumes reticulum is checked out in a sibling to this folder."
 fi
 
-rm -rf ../reticulum/priv/static ; GENERATE_SMOKE_TESTS=true BASE_ASSETS_PATH=http://localhost:4000/ yarn build -- --output-path ../reticulum/priv/static 
+rm -rf ../reticulum/priv/static ; GENERATE_SMOKE_TESTS=true BASE_ASSETS_PATH=https://localhost:4000/ yarn build -- --output-path ../reticulum/priv/static 
diff --git a/src/assets/images/device_entry.svg b/src/assets/images/device_entry.svg
new file mode 100755
index 0000000000000000000000000000000000000000..f406f0c8058b65ca14a04aee952f1cccc071ae6d
--- /dev/null
+++ b/src/assets/images/device_entry.svg
@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="94" height="94">
+  <g fill="none">
+    <path fill="#D8D8D8" fill-opacity=".01" fill-rule="evenodd" d="M47 92c24.853 0 45-20.147 45-45S71.853 2 47 2 2 22.147 2 47s20.147 45 45 45z" clip-rule="evenodd"/>
+    <path stroke="#fff" stroke-width="3" d="M47 92c24.853 0 45-20.147 45-45S71.853 2 47 2 2 22.147 2 47s20.147 45 45 45z"/>
+    <path fill="#C4C4C4" d="M59.5 39c1.792.29 5.44 1.047 6.43 1.549C65.525 28.767 59.048 26.819 55.5 29c4.222 1.121 4.102 7.456 4 10z"/>
+    <path fill="#C4C4C4" d="M59.5 39c1.792.29 5.44 1.047 6.43 1.549C65.525 28.767 59.048 26.819 55.5 29c4.222 1.121 4.102 7.456 4 10z"/>
+    <path fill="#fff" fill-rule="evenodd" stroke="#fff" d="M49.52 43.729a3 3 0 0 0 3.16-1.846 49.424 49.424 0 0 1 1.577-3.51c1.206.038 2.299.077 3.243.127 5.107.27 8.46 1.781 10.542 3.82-1.43.633-3.15 1.481-4.801 2.328a238.752 238.752 0 0 0-6.04 3.212l-.41.225-.147.081a1.5 1.5 0 0 0-.76 1.499l.01 8.465c.06.474.34.89.757 1.123.422.24.918.178 1.4.034l.13-.06a120.01 120.01 0 0 0 1.345-.628c.269-.127.567-.268.889-.422l.242-.116c1.51-.72 4.234-2.02 6.072-2.974a95.31 95.31 0 0 0 3.542-1.926c-.385 1.003-.908 1.845-1.501 2.425L51.5 65c-1.417-1.043-.933-1.604-.068-2.603 1.538-1.778 4.278-4.946-.35-14.69-5.62-4.425-18.234-4.507-23.614-4.543-1.739-.01-2.722-.017-2.468-.164 1.715-.99 12.942-4.158 16.658-5.076a149.626 149.626 0 0 1-.37.794 3 3 0 0 0 2.332 4.258l5.9.753z" clip-rule="evenodd"/>
+    <path fill="#fff" d="M44 40l5.9.753c7.82-19.229 15.691-11.946 16.2.548-.05-8.196-2.693-14.35-10.397-14.3C49.539 27.04 46.127 35.5 44 40z"/>
+    <mask id="a" fill="#fff">
+      <path d="M17.038 1.115C4.277 2.707.865 1.717 0 14.395c0 13.783 7.5 9.45 19.16 7.594 8.824-1.89 13.932-1.037 13.932-13.732C32.243-2.03 29.087-.39 17.038 1.115z"/>
+    </mask>
+    <g mask="url(#a)" transform="matrix(-1 0 0 1 55.092 41.876)">
+      <path fill="#fff" d="M0 14.395l-3.99-.272-.01.136v.136h4zm17.038-13.28l-.495-3.97.495 3.97zm16.054 7.142h4v-.165l-.014-.164-3.986.329zM19.159 21.989l.63 3.95.104-.016.104-.023-.838-3.911zM3.991 14.668c.436-6.386 1.492-7.26 2.128-7.642.579-.348 1.58-.681 3.606-.994 2.071-.32 4.408-.524 7.808-.948l-.99-7.939c-2.98.372-5.856.643-8.04.98-2.23.345-4.529.856-6.506 2.044C-2.479 2.86-3.56 7.83-3.99 14.123l7.982.545zm13.542-9.584c3.216-.401 5.411-.754 7.384-.955 1.975-.202 2.849-.133 3.247-.02.035.01.005-.093.144.198.276.582.593 1.798.797 4.279l7.973-.658c-.22-2.663-.615-5.099-1.546-7.056-1.068-2.247-2.8-3.776-5.166-4.453-2.003-.574-4.23-.456-6.262-.249-2.034.208-4.752.625-7.561.975l.99 7.939zm11.559 3.173c0 6.022-1.247 7.164-1.898 7.605-.552.373-1.418.71-3.007 1.065-.775.174-1.624.332-2.624.517-.978.181-2.076.384-3.241.634l1.675 7.822c1.04-.223 2.033-.406 3.023-.59.969-.18 1.967-.364 2.911-.575 1.852-.413 3.917-1.01 5.745-2.247 4.109-2.78 5.416-7.559 5.416-14.231h-8zM18.53 18.039c-2.982.475-5.911 1.147-8.04 1.557-2.36.456-3.825.61-4.797.49-.676-.082-.754-.224-.892-.459C4.465 19.057 4 17.62 4 14.395h-8c0 3.667.473 6.856 1.908 9.293 1.632 2.77 4.158 4.014 6.814 4.34 2.362.288 4.958-.128 7.283-.576 2.556-.494 4.937-1.06 7.783-1.513l-1.258-7.9z"/>
+    </g>
+    <path fill="#fff" d="M58.692 56v-5.897C62.741 48.19 67.26 45.81 70 44.5c1.86-.889 3.371-.179 3.74 1.69.26 1.31-.586 2.684-1.74 3.31-1.154.626-13.308 6.5-13.308 6.5z"/>
+  </g>
+</svg>
diff --git a/src/assets/stylesheets/info-dialog.scss b/src/assets/stylesheets/info-dialog.scss
index 47b06a43623b109a9fa27060d83f7da44889e59c..690db56c83a0fee301b0bb95952f6a1622ec9140 100644
--- a/src/assets/stylesheets/info-dialog.scss
+++ b/src/assets/stylesheets/info-dialog.scss
@@ -182,3 +182,17 @@
     }
   }
 }
+
+
+.info-dialog--action-button {
+  @extend %bottom-button;
+  margin-left: 6px;
+  margin-right: 6px;
+  appearance: none;
+  width: 168px;
+  text-align: center;
+  -moz-appearance: none;
+  -webkit-appearance: none;
+  margin: auto;
+  text-decoration: none;
+}
diff --git a/src/assets/stylesheets/link-dialog.scss b/src/assets/stylesheets/link-dialog.scss
new file mode 100644
index 0000000000000000000000000000000000000000..aafcac281e33a362fc69871f9474fff32ddd73b7
--- /dev/null
+++ b/src/assets/stylesheets/link-dialog.scss
@@ -0,0 +1,29 @@
+:local(.domain) , :local(.code) {
+  color: white;
+  font-family: monospace;
+  font-weight: bold;
+  text-decoration: none;
+}
+
+:local(.domain) {
+  font-size: 3em;
+  padding: 14px;
+  display: block;
+}
+
+:local(.code) {
+  font-size: 4.0em;
+  padding: 8px;
+}
+
+:local(.keep-open) {
+  font-size: 0.8em;
+}
+
+:local(.digit) {
+  padding: 0 8px;
+}
+
+:local(.code-loading-panel) {
+  background: none;
+}
diff --git a/src/assets/stylesheets/link.scss b/src/assets/stylesheets/link.scss
new file mode 100644
index 0000000000000000000000000000000000000000..63da92c9709fa72367461af1006d85de03e4873a
--- /dev/null
+++ b/src/assets/stylesheets/link.scss
@@ -0,0 +1,181 @@
+@import 'shared';
+@import 'loader';
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  padding: 0;
+  background-color: black;
+  color: white;
+}
+
+a {
+  color: white;
+}
+
+.link-root {
+  @extend %default-font;
+
+  width: 100%;
+  height: 100%;
+  margin: 0;
+  padding: 0;
+
+  position: absolute;
+}
+
+:local(.link) {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+}
+
+:local(.link-contents) {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  color: $grey-text;
+  font-size: 1.4em;
+
+  @media (max-width: 690px) {
+    flex-direction: column;
+  }
+}
+
+:local(.entered-footer) {
+  margin: 16px;
+  font-size: 0.8em;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+
+  @media (max-width: 690px) {
+    display: none;
+  }
+}
+
+:local(.entered-contents) {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+
+:local(.code-loading-panel) {
+  background: rgba(0.4, 0.4, 0.4, 0.85);
+}
+
+:local(.entry-footer-image) {
+  width: 200px;
+  margin: 12px;
+}
+
+
+:local(.footer-image) {
+  width: 200px;
+  margin: 12px;
+
+  @media (max-height: 719px) {
+    display: none;
+  }
+}
+
+:local(.header) {
+  margin: 16px;
+}
+
+:local(.footer) {
+  margin: 16px;
+  font-size: 0.8em;
+
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+
+  @media (min-width: 690px) , (max-height: 650px) {
+    display: none;
+  }
+}
+
+:local(.keypad) {
+  display: grid;
+  grid-template-columns: 1fr 1fr 1fr;
+  grid-template-rows: 1fr 1fr 1fr 1fr;
+}
+
+:local(.keypad-button) {
+  @extend %big-icon-button;
+  font-size: 1.8em;
+  font-family: sans-serif;
+  border: 4px $light-grey solid;
+  border-radius: 128px;
+  min-width: 88px;
+  min-height: 88px;
+  cursor: pointer;
+  line-height: 68px;
+  margin: 8px;
+}
+
+:local(.keypad-button):active {
+  background-color: $darker-grey;
+}
+
+:local(.keypad-zero-button) {
+  grid-column: 2;
+}
+
+:local(.keypad-button):disabled {
+  color: $light-grey;
+  border: 6px $dark-grey solid;
+}
+
+:local(.keypad-backspace) , :local(.keypad-backspace):disabled , :local(.keypad-backspace):active {
+  border: none;
+}
+
+:local(.keypad-backspace):active {
+  background-color: transparent;
+  color: $light-grey;
+}
+
+:local(.entered-digits) {
+  font-face: monospace;
+  height: 100px;
+  width: 300px;
+  text-align: center;
+  font-size: 3.0em;
+  color: white;
+  display: flex;
+  justify-content: center;
+}
+
+:local(.digit) {
+  margin: 8px;
+}
+
+:local(.digit-input) {
+  outline-style: none;
+  appearance:textfield;
+  -moz-appearance:textfield;
+  -webkit-appearance:textfield;
+  background: transparent;
+  color: white;
+  margin: 0;
+  font-size: 64pt;
+  border: 0;
+  width: 225px;
+  letter-spacing: 0.08em;
+  text-align: center;
+}
+
+:local(.digit-input::placeholder) {
+  letter-spacing: 0;
+}
+
diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json
index 7dcd32f8f81d394ed06aa11ee117823a721b0f1f..c6ef31c5c944ea85568fb6fe1cded6c47e0341b7 100644
--- a/src/assets/translations.data.json
+++ b/src/assets/translations.data.json
@@ -5,15 +5,19 @@
     "entry.mobile-screen": "Phone",
     "entry.generic-prefix": "Enter in ",
     "entry.generic-medium": "VR",
+    "entry.generic-subtitle-desktop": "Oculus or SteamVR",
     "entry.gearvr-prefix": "Enter on ",
-    "entry.gearvr-medium": "GearVR",
+    "entry.gearvr-medium": "Gear VR",
+    "entry.device-prefix-desktop": "Send to ",
+    "entry.device-prefix-mobile": "Enter on ",
+    "entry.device-medium": "Device",
+    "entry.device-subtitle-desktop": "Standalone Headset or Phone",
+    "entry.device-subtitle-mobile": "Mobile Headset or PC",
     "entry.cardboard": "Enter on Google Cardboard",
     "entry.daydream-prefix": "Enter on ",
     "entry.daydream-medium": "Daydream",
     "entry.daydream-via-chrome": "Using Google Chrome",
     "entry.enable-screen-sharing": "Share my desktop",
-    "entry.webvr-link-preamble": "New to WebVR?",
-    "entry.webvr-link": "Learn more",
     "profile.save": "SAVE",
     "profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32",
     "profile.header": "Your display name:",
@@ -56,6 +60,13 @@
     "home.environment_author_by": " by ",
     "home.dialog.close": "CLOSE",
     "mailing_list.privacy_label": "I'm okay with Mozilla handling my info as explained in",
-    "mailing_list.privacy_link": "this Privacy Notice"
+    "mailing_list.privacy_link": "this Privacy Notice",
+    "link.in_your_browser": "In your device's browser, go to:",
+    "link.enter_code": "Then, enter this code:",
+    "link.do_not_close": "Keep this dialog open to use this code.",
+    "link.link_page_header": "Enter your code:",
+    "link.dont_have_a_code": "Don't have a code?",
+    "link.create_a_room": "Create a Room",
+    "link.try_again": "We couldn't find that code. Please try again."
   }
 }
diff --git a/src/hub.js b/src/hub.js
index 27963943373e9b748120b861f902fe64adeda2b3..f85a4980f3dd50dbda64fc368627d77fe42c0d1a 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -1,8 +1,6 @@
 import "./assets/stylesheets/hub.scss";
 import moment from "moment-timezone";
-import uuid from "uuid/v4";
 import queryString from "query-string";
-import { Socket } from "phoenix";
 
 import { patchWebGLRenderingContext } from "./utils/webgl";
 patchWebGLRenderingContext();
@@ -69,6 +67,8 @@ import ReactDOM from "react-dom";
 import React from "react";
 import UIRoot from "./react-components/ui-root";
 import HubChannel from "./utils/hub-channel";
+import LinkChannel from "./utils/link-channel";
+import { connectToReticulum } from "./utils/phoenix-utils";
 
 import "./systems/personal-space-bubble";
 import "./systems/app-mode";
@@ -118,7 +118,6 @@ import registerNetworkSchemas from "./network-schemas";
 import { inGameActions, config as inputConfig } from "./input-mappings";
 import registerTelemetry from "./telemetry";
 
-import { generateDefaultProfile, generateRandomName } from "./utils/identity.js";
 import { getAvailableVREntryTypes, VR_DEVICE_AVAILABILITY } from "./utils/vr-caps-detect.js";
 import ConcurrentLoadDetector from "./utils/concurrent-load-detector.js";
 
@@ -145,13 +144,7 @@ const concurrentLoadDetector = new ConcurrentLoadDetector();
 
 concurrentLoadDetector.start();
 
-// Always layer in any new default profile bits
-store.update({ activity: {}, settings: {}, profile: { ...generateDefaultProfile(), ...(store.state.profile || {}) } });
-
-// Regenerate name to encourage users to change it.
-if (!store.state.activity.hasChangedName) {
-  store.update({ profile: { displayName: generateRandomName() } });
-}
+store.init();
 
 function mountUI(scene, props = {}) {
   const disableAutoExitOnConcurrentLoad = qsTruthy("allow_multi");
@@ -181,13 +174,14 @@ function mountUI(scene, props = {}) {
 const onReady = async () => {
   const scene = document.querySelector("a-scene");
   const hubChannel = new HubChannel(store);
+  const linkChannel = new LinkChannel(store);
 
   document.querySelector("canvas").classList.add("blurred");
   window.APP.scene = scene;
 
   registerNetworkSchemas();
 
-  let uiProps = {};
+  let uiProps = { linkChannel };
 
   mountUI(scene);
 
@@ -421,17 +415,7 @@ const onReady = async () => {
   const hubId = qs.hub_id || document.location.pathname.substring(1).split("/")[0];
   console.log(`Hub ID: ${hubId}`);
 
-  const socketProtocol = document.location.protocol === "https:" ? "wss:" : "ws:";
-  const [retHost, retPort] = (process.env.DEV_RETICULUM_SERVER || "").split(":");
-  const isProd = process.env.NODE_ENV === "production";
-  const socketPort = qs.phx_port || (isProd ? document.location.port : retPort) || "443";
-  const socketHost = qs.phx_host || (isProd ? document.location.hostname : retHost) || "";
-  const socketUrl = `${socketProtocol}//${socketHost}${socketPort ? `:${socketPort}` : ""}/socket`;
-  console.log(`Phoenix Channel URL: ${socketUrl}`);
-
-  const socket = new Socket(socketUrl, { params: { session_id: uuid() } });
-  socket.connect();
-
+  const socket = connectToReticulum();
   const channel = socket.channel(`hub:${hubId}`, {});
 
   channel
@@ -452,6 +436,8 @@ const onReady = async () => {
 
       console.error(res);
     });
+
+  linkChannel.setSocket(socket);
 };
 
 document.addEventListener("DOMContentLoaded", onReady);
diff --git a/src/link.html b/src/link.html
new file mode 100644
index 0000000000000000000000000000000000000000..8f44654c0562b9ce2afd5b3bb95f482fffc213ab
--- /dev/null
+++ b/src/link.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <link rel="shortcut icon" type="image/png" href="/favicon.ico"/>
+    <title>Enter Code | Hubs by Mozilla</title>
+    <link href="https://fonts.googleapis.com/css?family=Zilla+Slab:300,300i,400,400i,700" rel="stylesheet">
+</head>
+
+<body>
+    <div id="link-root" class="link-root"></div>
+</body>
+
+</html>
diff --git a/src/link.js b/src/link.js
new file mode 100644
index 0000000000000000000000000000000000000000..401fe54d9b8b9bd91df1c2140257710502a23add
--- /dev/null
+++ b/src/link.js
@@ -0,0 +1,20 @@
+import "./assets/stylesheets/link.scss";
+import React from "react";
+import ReactDOM from "react-dom";
+import registerTelemetry from "./telemetry";
+import LinkRoot from "./react-components/link-root";
+import LinkChannel from "./utils/link-channel";
+import { connectToReticulum } from "./utils/phoenix-utils";
+import Store from "./storage/store";
+
+registerTelemetry();
+
+const socket = connectToReticulum();
+const store = new Store();
+store.init();
+
+const linkChannel = new LinkChannel(store);
+
+linkChannel.setSocket(socket);
+
+ReactDOM.render(<LinkRoot store={store} linkChannel={linkChannel} />, document.getElementById("link-root"));
diff --git a/src/react-components/entry-buttons.js b/src/react-components/entry-buttons.js
index 92d0ef5ef8ccd4992d9913bfb30144fff018c0b9..90dd40cfd23724418228cc7bb3b46fe45dc8e66f 100644
--- a/src/react-components/entry-buttons.js
+++ b/src/react-components/entry-buttons.js
@@ -7,7 +7,8 @@ import MobileScreenEntryImg from "../assets/images/mobile_screen_entry.svg";
 import DesktopScreenEntryImg from "../assets/images/desktop_screen_entry.svg";
 import GenericVREntryImg from "../assets/images/generic_vr_entry.svg";
 import GearVREntryImg from "../assets/images/gearvr_entry.svg";
-import DaydreamEntyImg from "../assets/images/daydream_entry.svg";
+import DaydreamEntryImg from "../assets/images/daydream_entry.svg";
+import DeviceEntryImg from "../assets/images/device_entry.svg";
 
 const mobiledetect = new MobileDetect(navigator.userAgent);
 
@@ -22,7 +23,11 @@ const EntryButton = props => (
         <span className="entry-button--bolded">
           <FormattedMessage id={props.mediumMessageId} />
         </span>
-        {props.subtitle && <div className="entry-button__subtitle">{props.subtitle}</div>}
+        {props.subtitle && (
+          <div className="entry-button__subtitle">
+            <FormattedMessage id={props.subtitle} />
+          </div>
+        )}
       </div>
     </div>
   </button>
@@ -52,7 +57,8 @@ export const GenericEntryButton = props => {
     ...props,
     iconSrc: GenericVREntryImg,
     prefixMessageId: "entry.generic-prefix",
-    mediumMessageId: "entry.generic-medium"
+    mediumMessageId: "entry.generic-medium",
+    subtitle: mobiledetect.mobile() ? null : "entry.generic-subtitle-desktop"
   };
 
   return <EntryButton {...entryButtonProps} />;
@@ -72,10 +78,22 @@ export const GearVREntryButton = props => {
 export const DaydreamEntryButton = props => {
   const entryButtonProps = {
     ...props,
-    iconSrc: DaydreamEntyImg,
+    iconSrc: DaydreamEntryImg,
     prefixMessageId: "entry.daydream-prefix",
     mediumMessageId: "entry.daydream-medium"
   };
 
   return <EntryButton {...entryButtonProps} />;
 };
+
+export const DeviceEntryButton = props => {
+  const entryButtonProps = {
+    ...props,
+    iconSrc: DeviceEntryImg,
+    prefixMessageId: mobiledetect.mobile() ? "entry.device-prefix-mobile" : "entry.device-prefix-desktop",
+    mediumMessageId: "entry.device-medium",
+    subtitle: mobiledetect.mobile() ? "entry.device-subtitle-mobile" : "entry.device-subtitle-desktop"
+  };
+
+  return <EntryButton {...entryButtonProps} />;
+};
diff --git a/src/react-components/info-dialog.js b/src/react-components/info-dialog.js
index 6e27e39c91262cf7e23a2597d00793f5f172f34b..7a48099b400203abcadced08ac5c5df6cb41552d 100644
--- a/src/react-components/info-dialog.js
+++ b/src/react-components/info-dialog.js
@@ -4,6 +4,7 @@ import classNames from "classnames";
 import PropTypes from "prop-types";
 import { FormattedMessage } from "react-intl";
 import formurlencoded from "form-urlencoded";
+import LinkDialog from "./link-dialog.js";
 
 // TODO i18n
 
@@ -14,12 +15,15 @@ class InfoDialog extends Component {
     invite: Symbol("invite"),
     updates: Symbol("updates"),
     report: Symbol("report"),
-    help: Symbol("help")
+    help: Symbol("help"),
+    link: Symbol("link"),
+    webvr_recommend: Symbol("webvr_recommend")
   };
   static propTypes = {
     dialogType: PropTypes.oneOf(Object.values(InfoDialog.dialogTypes)),
     onCloseDialog: PropTypes.func,
-    onSubmittedEmail: PropTypes.func
+    onSubmittedEmail: PropTypes.func,
+    linkCode: PropTypes.string
   };
 
   constructor(props) {
@@ -248,6 +252,27 @@ class InfoDialog extends Component {
           </div>
         );
         break;
+      case InfoDialog.dialogTypes.webvr_recommend:
+        dialogTitle = "Enter in VR";
+        dialogBody = (
+          <div>
+            <p>To enter Hubs with Oculus or SteamVR, you can use Firefox.</p>
+            <a className="info-dialog--action-button" href="https://www.mozilla.org/firefox">
+              Download Firefox
+            </a>
+            <p style={{ fontSize: "0.8em" }}>
+              For a full list of browsers with experimental VR support, visit{" "}
+              <a href="https://webvr.rocks" target="_blank" rel="noopener noreferrer">
+                WebVR Rocks
+              </a>.
+            </p>
+          </div>
+        );
+        break;
+      case InfoDialog.dialogTypes.link:
+        dialogTitle = "Send Link to Device";
+        dialogBody = <LinkDialog linkCode={this.props.linkCode} />;
+        break;
     }
 
     const dialogClasses = classNames({
diff --git a/src/react-components/link-dialog.js b/src/react-components/link-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..80ab360d50d54da5738a9c3c771b090b0d4bc9fa
--- /dev/null
+++ b/src/react-components/link-dialog.js
@@ -0,0 +1,54 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import classNames from "classnames";
+import { FormattedMessage } from "react-intl";
+
+import styles from "../assets/stylesheets/link-dialog.scss";
+
+class LinkDialog extends Component {
+  static propTypes = {
+    linkCode: PropTypes.string
+  };
+
+  render() {
+    if (!this.props.linkCode) {
+      return (
+        <div>
+          <div className={classNames("loading-panel", styles.codeLoadingPanel)}>
+            <div className="loader-wrap">
+              <div className="loader">
+                <div className="loader-center" />
+              </div>
+            </div>
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <div>
+        <div>
+          <FormattedMessage id="link.in_your_browser" />
+        </div>
+        <a href="https://hub.link" className={styles.domain} target="_blank" rel="noopener noreferrer">
+          hub.link
+        </a>
+        <div>
+          <FormattedMessage id="link.enter_code" />
+        </div>
+        <div className={styles.code}>
+          {this.props.linkCode.split("").map((d, i) => (
+            <span className={styles.digit} key={`link_code_${i}`}>
+              {d}
+            </span>
+          ))}
+        </div>
+        <div className={styles.keepOpen}>
+          <FormattedMessage id="link.do_not_close" />
+        </div>
+      </div>
+    );
+  }
+}
+
+export default LinkDialog;
diff --git a/src/react-components/link-root.js b/src/react-components/link-root.js
new file mode 100644
index 0000000000000000000000000000000000000000..1ab8aa47829ad5181f3796b72502d3f587241b55
--- /dev/null
+++ b/src/react-components/link-root.js
@@ -0,0 +1,176 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl";
+import en from "react-intl/locale-data/en";
+
+import { lang, messages } from "../utils/i18n";
+import classNames from "classnames";
+import styles from "../assets/stylesheets/link.scss";
+
+const MAX_DIGITS = 4;
+
+addLocaleData([...en]);
+
+class LinkRoot extends Component {
+  static propTypes = {
+    intl: PropTypes.object,
+    store: PropTypes.object,
+    linkChannel: PropTypes.object
+  };
+
+  state = {
+    enteredDigits: "",
+    failedAtLeastOnce: false
+  };
+
+  componentWillMount = () => {
+    document.addEventListener("keydown", this.handleKeyDown);
+  };
+
+  componentWillUnmount = () => {
+    document.removeEventListener("keydown", this.handleKeyDown);
+  };
+
+  handleKeyDown = e => {
+    // Number keys 0-9
+    if (e.keyCode < 48 || e.keyCode > 57) {
+      return;
+    }
+
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.addDigit(e.keyCode - 48);
+  };
+
+  addDigit = digit => {
+    if (this.state.enteredDigits.length >= MAX_DIGITS) return;
+    const newDigits = `${this.state.enteredDigits}${digit}`;
+
+    if (newDigits.length === MAX_DIGITS) {
+      this.attemptLink(newDigits);
+    }
+
+    this.setState({ enteredDigits: newDigits });
+  };
+
+  removeDigit = () => {
+    const enteredDigits = this.state.enteredDigits;
+    if (enteredDigits.length === 0) return;
+    this.setState({ enteredDigits: enteredDigits.substring(0, enteredDigits.length - 1) });
+  };
+
+  attemptLink = code => {
+    this.props.linkChannel
+      .attemptLink(code)
+      .then(response => {
+        // If there is a profile from the linked device, copy it over if we don't have one yet.
+        if (response.profile) {
+          const { hasChangedName } = this.props.store.state.activity;
+
+          if (!hasChangedName) {
+            this.props.store.update({ activity: { hasChangedName: true }, profile: response.profile });
+          }
+        }
+
+        if (response.path) {
+          window.location.href = response.path;
+        }
+      })
+      .catch(e => {
+        console.error(e);
+        this.setState({ failedAtLeastOnce: true, enteredDigits: "" });
+      });
+  };
+
+  render() {
+    return (
+      <IntlProvider locale={lang} messages={messages}>
+        <div className={styles.link}>
+          <div className={styles.linkContents}>
+            {this.state.enteredDigits.length === MAX_DIGITS && (
+              <div className={classNames("loading-panel", styles.codeLoadingPanel)}>
+                <div className="loader-wrap">
+                  <div className="loader">
+                    <div className="loader-center" />
+                  </div>
+                </div>
+              </div>
+            )}
+
+            <div className={styles.enteredContents}>
+              <div className={styles.header}>
+                <FormattedMessage id={this.state.failedAtLeastOnce ? "link.try_again" : "link.link_page_header"} />
+              </div>
+
+              <div className={styles.enteredDigits}>
+                <input
+                  className={styles.digitInput}
+                  type="number"
+                  value={this.state.enteredDigits}
+                  onChange={ev => {
+                    this.setState({ enteredDigits: ev.target.value });
+                  }}
+                  placeholder="- - - -"
+                />
+              </div>
+
+              <div className={styles.enteredFooter}>
+                <span>
+                  <FormattedMessage id="link.dont_have_a_code" />
+                </span>{" "}
+                <span>
+                  <a href="/">
+                    <FormattedMessage id="link.create_a_room" />
+                  </a>
+                </span>
+                <img className={styles.entryFooterImage} src="../assets/images/logo.svg" />
+              </div>
+            </div>
+
+            <div className={styles.keypad}>
+              {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((d, i) => (
+                <button
+                  disabled={this.state.enteredDigits.length === MAX_DIGITS}
+                  key={`digit_${i}`}
+                  className={styles.keypadButton}
+                  onClick={() => this.addDigit(d)}
+                >
+                  {d}
+                </button>
+              ))}
+              <button
+                disabled={this.state.enteredDigits.length === MAX_DIGITS}
+                className={classNames(styles.keypadButton, styles.keypadZeroButton)}
+                onClick={() => this.addDigit(0)}
+              >
+                0
+              </button>
+              <button
+                disabled={this.state.enteredDigits.length === 0 || this.state.enteredDigits.length === MAX_DIGITS}
+                className={classNames(styles.keypadButton, styles.keypadBackspace)}
+                onClick={() => this.removeDigit()}
+              >
+                ⌫
+              </button>
+            </div>
+
+            <div className={styles.footer}>
+              <span>
+                <FormattedMessage id="link.dont_have_a_code" />
+              </span>{" "}
+              <span>
+                <a href="/">
+                  <FormattedMessage id="link.create_a_room" />
+                </a>
+              </span>
+              <img className={styles.footerImage} src="../assets/images/logo.svg" alt="Logo" />
+            </div>
+          </div>
+        </div>
+      </IntlProvider>
+    );
+  }
+}
+
+export default LinkRoot;
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index 849cd21eb49d0bf27c4eea7c08d7ad7e2de909bc..803c28acded3354c4065be0fa380424e0b7c2237 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -11,7 +11,7 @@ import screenfull from "screenfull";
 
 import { lang, messages } from "../utils/i18n";
 import AutoExitWarning from "./auto-exit-warning";
-import { TwoDEntryButton, GenericEntryButton, GearVREntryButton, DaydreamEntryButton } from "./entry-buttons.js";
+import { TwoDEntryButton, DeviceEntryButton, GenericEntryButton, DaydreamEntryButton } from "./entry-buttons.js";
 import { ProfileInfoHeader } from "./profile-info-header.js";
 import ProfileEntryPanel from "./profile-entry-panel";
 import InfoDialog from "./info-dialog.js";
@@ -60,6 +60,7 @@ class UIRoot extends Component {
     enableScreenSharing: PropTypes.bool,
     store: PropTypes.object,
     scene: PropTypes.object,
+    linkChannel: PropTypes.object,
     htmlPrefix: PropTypes.string,
     showProfileEntry: PropTypes.bool,
     availableVREntryTypes: PropTypes.object,
@@ -75,6 +76,8 @@ class UIRoot extends Component {
     entryStep: ENTRY_STEPS.start,
     enterInVR: false,
     infoDialogType: null,
+    linkCode: null,
+    linkCodeCancel: null,
 
     shareScreen: false,
     requestedScreen: false,
@@ -158,8 +161,6 @@ class UIRoot extends Component {
 
     if (this.props.forcedVREntryType === "daydream") {
       this.enterDaydream();
-    } else if (this.props.forcedVREntryType === "gearvr") {
-      this.enterGearVR();
     } else if (this.props.forcedVREntryType === "vr") {
       this.enterVR();
     } else if (this.props.forcedVREntryType === "2d") {
@@ -260,25 +261,10 @@ class UIRoot extends Component {
   };
 
   enterVR = async () => {
-    await this.performDirectEntryFlow(true);
-  };
-
-  enterGearVR = async () => {
-    if (this.props.availableVREntryTypes.gearvr === VR_DEVICE_AVAILABILITY.yes) {
+    if (this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.maybe) {
       await this.performDirectEntryFlow(true);
     } else {
-      this.exit();
-
-      // Launch via Oculus Browser
-      const location = window.location;
-      const qs = queryString.parse(location.search);
-      qs.vr_entry_type = "gearvr"; // Auto-choose 'gearvr' after landing in Oculus Browser
-
-      const ovrwebUrl =
-        `ovrweb://${location.protocol || "http:"}//${location.host}` +
-        `${location.pathname || ""}?${queryString.stringify(qs)}#${location.hash || ""}`;
-
-      window.location = ovrwebUrl;
+      this.setState({ infoDialogType: InfoDialog.dialogTypes.webvr_recommend });
     }
   };
 
@@ -513,6 +499,21 @@ class UIRoot extends Component {
     this.setState({ entryStep: ENTRY_STEPS.finished });
   };
 
+  attemptLink = async () => {
+    this.setState({ infoDialogType: InfoDialog.dialogTypes.link });
+    const { code, cancel, onFinished } = await this.props.linkChannel.generateCode();
+    this.setState({ linkCode: code, linkCodeCancel: cancel });
+    onFinished.then(this.handleCloseDialog);
+  };
+
+  handleCloseDialog = async () => {
+    if (this.state.linkCodeCancel) {
+      this.state.linkCodeCancel();
+    }
+
+    this.setState({ infoDialogType: null, linkCode: null, linkCodeCancel: null });
+  };
+
   render() {
     if (this.state.exited || this.props.roomUnavailableReason || this.props.platformUnsupportedReason) {
       let subtitle = null;
@@ -543,7 +544,10 @@ class UIRoot extends Component {
               rel="noreferrer noopener"
             >
               WebRTC Data Channels
-            </a>, which is required to use Hubs.
+            </a>, which is required to use Hubs.<br />If you&quot;d like to use Hubs with Oculus or SteamVR, you can{" "}
+            <a href="https://www.mozilla.org/firefox" rel="noreferrer noopener">
+              Download Firefox
+            </a>.
           </div>
         );
       } else {
@@ -588,8 +592,6 @@ class UIRoot extends Component {
       );
     }
 
-    const daydreamMaybeSubtitle = messages["entry.daydream-via-chrome"];
-
     // Only show this in desktop firefox since other browsers/platforms will ignore the "screen" media constraint and
     // will attempt to share your webcam instead!
     const screenSharingCheckbox = this.props.enableScreenSharing &&
@@ -614,17 +616,17 @@ class UIRoot extends Component {
             {this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && (
               <GenericEntryButton onClick={this.enterVR} />
             )}
-            {this.props.availableVREntryTypes.gearvr !== VR_DEVICE_AVAILABILITY.no && (
-              <GearVREntryButton onClick={this.enterGearVR} />
-            )}
             {this.props.availableVREntryTypes.daydream !== VR_DEVICE_AVAILABILITY.no && (
               <DaydreamEntryButton
                 onClick={this.enterDaydream}
                 subtitle={
-                  this.props.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe ? daydreamMaybeSubtitle : ""
+                  this.props.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe
+                    ? "entry.daydream-via-chrome"
+                    : null
                 }
               />
             )}
+            <DeviceEntryButton onClick={this.attemptLink} />
             {this.props.availableVREntryTypes.cardboard !== VR_DEVICE_AVAILABILITY.no && (
               <div className="entry-panel__secondary" onClick={this.enterVR}>
                 <FormattedMessage id="entry.cardboard" />
@@ -632,19 +634,6 @@ class UIRoot extends Component {
             )}
             {screenSharingCheckbox}
           </div>
-          {!mobiledetect.mobile() && (
-            <div className="entry-panel__webvr-link-container">
-              <FormattedMessage id="entry.webvr-link-preamble" />{" "}
-              <a
-                className="entry-panel__webvr-link"
-                target="_blank"
-                rel="noopener noreferrer"
-                href="https://webvr.rocks/"
-              >
-                <FormattedMessage id="entry.webvr-link" />
-              </a>
-            </div>
-          )}
         </div>
       ) : null;
 
@@ -675,12 +664,7 @@ class UIRoot extends Component {
             </div>
           </div>
           <div className="mic-grant-panel__next-container">
-            <button
-              className={classNames("mic-grant-panel__next", {
-                invisible: this.state.entryStep === ENTRY_STEPS.mic_grant
-              })}
-              onClick={this.onMicGrantButton}
-            >
+            <button className={classNames("mic-grant-panel__next")} onClick={this.onMicGrantButton}>
               <FormattedMessage id="audio.granted-next" />
             </button>
           </div>
@@ -829,8 +813,9 @@ class UIRoot extends Component {
         <div className="ui">
           <InfoDialog
             dialogType={this.state.infoDialogType}
+            linkCode={this.state.linkCode}
             onSubmittedEmail={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.email_submitted })}
-            onCloseDialog={() => this.setState({ infoDialogType: null })}
+            onCloseDialog={this.handleCloseDialog}
           />
 
           {this.state.entryStep === ENTRY_STEPS.finished && (
diff --git a/src/storage/store.js b/src/storage/store.js
index 23f3168198f11a6824d0f1a4bdd63d67a0ef5961..e4e509ba3c1f1fc41b45d6816ba31f542f9c09cd 100644
--- a/src/storage/store.js
+++ b/src/storage/store.js
@@ -5,6 +5,7 @@ const LOCAL_STORE_KEY = "___hubs_store";
 const STORE_STATE_CACHE_KEY = Symbol();
 const validator = new Validator();
 import { EventTarget } from "event-target-shim";
+import { generateDefaultProfile, generateRandomName } from "../utils/identity.js";
 
 // Durable (via local-storage) schema-enforced state that is meant to be consumed via forward data flow.
 // (Think flux but with way less incidental complexity, at least for now :))
@@ -60,6 +61,20 @@ export default class Store extends EventTarget {
     }
   }
 
+  // Initializes store with any default bits
+  init = () => {
+    this.update({
+      activity: {},
+      settings: {},
+      profile: { ...generateDefaultProfile(), ...(this.state.profile || {}) }
+    });
+
+    // Regenerate name to encourage users to change it.
+    if (!this.state.activity.hasChangedName) {
+      this.update({ profile: { displayName: generateRandomName() } });
+    }
+  };
+
   get state() {
     if (!this.hasOwnProperty(STORE_STATE_CACHE_KEY)) {
       this[STORE_STATE_CACHE_KEY] = JSON.parse(localStorage.getItem(LOCAL_STORE_KEY));
diff --git a/src/utils/crypto.js b/src/utils/crypto.js
new file mode 100644
index 0000000000000000000000000000000000000000..53fd606e98657c896da161edb97f93272a684ef7
--- /dev/null
+++ b/src/utils/crypto.js
@@ -0,0 +1,76 @@
+// NOTE: We do not use an IV since we generate a new keypair each time we use these routines.
+
+async function deriveKey(privateKey, publicKey) {
+  return crypto.subtle.deriveKey(
+    { name: "ECDH", public: publicKey },
+    privateKey,
+    { name: "AES-CBC", length: 256 },
+    true,
+    ["encrypt", "decrypt"]
+  );
+}
+
+async function publicKeyToString(key) {
+  return JSON.stringify(await crypto.subtle.exportKey("jwk", key));
+}
+
+async function stringToPublicKey(s) {
+  return await crypto.subtle.importKey("jwk", JSON.parse(s), { name: "ECDH", namedCurve: "P-256" }, true, []);
+}
+
+function stringToArrayBuffer(s) {
+  const buf = new Uint8Array(s.length);
+
+  for (let i = 0; i < s.length; i++) {
+    buf[i] = s.charCodeAt(i);
+  }
+
+  return buf;
+}
+
+function arrayBufferToString(b) {
+  const buf = new Uint8Array(b);
+  let s = "";
+
+  for (let i = 0; i < buf.byteLength; i++) {
+    s += String.fromCharCode(buf[i]);
+  }
+
+  return s;
+}
+
+// This allows a single object to be passed encrypted from a receiver in a req -> response flow
+
+// Requestor generates a public key and private key, and should send the public key to receiver.
+export async function generateKeys() {
+  const keyPair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]);
+  const publicKeyString = await publicKeyToString(keyPair.publicKey);
+  return { publicKeyString, privateKey: keyPair.privateKey };
+}
+
+// Receiver takes the public key from requestor and passes obj to get a response public key and the encrypted data to return.
+export async function generatePublicKeyAndEncryptedObject(incomingPublicKeyString, obj) {
+  const iv = new Uint8Array(16);
+  const incomingPublicKey = await stringToPublicKey(incomingPublicKeyString);
+  const keyPair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]);
+  const publicKeyString = await publicKeyToString(keyPair.publicKey);
+  const secret = await deriveKey(keyPair.privateKey, incomingPublicKey);
+
+  const encryptedData = btoa(
+    arrayBufferToString(
+      await crypto.subtle.encrypt({ name: "AES-CBC", iv }, secret, stringToArrayBuffer(JSON.stringify(obj)))
+    )
+  );
+
+  return { publicKeyString, encryptedData };
+}
+
+// Requestor then takes the receiver's public key, the private key (returned from generateKeys()), and the data from the receiver.
+export async function decryptObject(publicKeyString, privateKey, base64value) {
+  const iv = new Uint8Array(16);
+  const publicKey = await stringToPublicKey(publicKeyString);
+  const secret = await deriveKey(privateKey, publicKey);
+  const ciphertext = stringToArrayBuffer(atob(base64value));
+  const data = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, secret, ciphertext);
+  return JSON.parse(arrayBufferToString(data));
+}
diff --git a/src/utils/link-channel.js b/src/utils/link-channel.js
new file mode 100644
index 0000000000000000000000000000000000000000..4da4e1eb2795c248cd7b4ddeb134549706d1ac0a
--- /dev/null
+++ b/src/utils/link-channel.js
@@ -0,0 +1,143 @@
+import { generatePublicKeyAndEncryptedObject, generateKeys, decryptObject } from "./crypto";
+
+const LINK_ACTION_TIMEOUT = 10000;
+
+export default class LinkChannel {
+  constructor(store) {
+    this.store = store;
+  }
+
+  setSocket = socket => {
+    this.socket = socket;
+  };
+
+  // Returns a promise that, when resolved, will forward an object with three keys:
+  //
+  // code: The code that was made available to use for link.
+  //
+  // cancel: A function that the caller can call to cancel the use of the code.
+  //
+  // onFinished: A promise that, when resolved, indicates the code is no longer usable,
+  // because it was either successfully used by the remote device or it has expired
+  // ("used" or "expired" is passed to the callback).
+  generateCode = () => {
+    return new Promise(resolve => {
+      const onFinished = new Promise(finished => {
+        const step = () => {
+          const code = Math.floor(Math.random() * 9999)
+            .toString()
+            .padStart(4, "0");
+
+          // Only respond to one link_request in this channel.
+          let readyToSend = false;
+          let leftChannel = false;
+
+          const channel = this.socket.channel(`link:${code}`, { timeout: LINK_ACTION_TIMEOUT });
+
+          const leave = () => {
+            if (!leftChannel) channel.leave();
+            leftChannel = true;
+          };
+
+          const cancel = () => leave();
+
+          channel.on("link_expired", () => finished("expired"));
+
+          channel.on("presence_state", state => {
+            if (readyToSend) return;
+
+            if (Object.keys(state).length > 0) {
+              // Code is in use by someone else, try a new one
+              step();
+            } else {
+              readyToSend = true;
+              resolve({ code, cancel, onFinished });
+            }
+          });
+
+          channel.on("link_request", incoming => {
+            if (readyToSend) {
+              const data = { path: location.pathname };
+
+              // Copy profile data to link'ed device if it's been set.
+              if (this.store.state.activity.hasChangedName) {
+                data.profile = { ...this.store.state.profile };
+              }
+
+              generatePublicKeyAndEncryptedObject(incoming.public_key, data).then(
+                ({ publicKeyString, encryptedData }) => {
+                  const payload = {
+                    target_session_id: incoming.reply_to_session_id,
+                    public_key: publicKeyString,
+                    data: encryptedData
+                  };
+
+                  if (!leftChannel) {
+                    channel.push("link_response", payload);
+                  }
+
+                  leave();
+
+                  finished("used");
+                  readyToSend = false;
+                }
+              );
+            }
+          });
+
+          channel.join().receive("error", r => console.error(r));
+        };
+
+        step();
+      });
+    });
+  };
+
+  // Attempts to receive a link payload from a remote device using the given code.
+  //
+  // Promise rejects if the code is invalid or there is a problem with the channel.
+  // Promise resolves and passes payload of link source on successful link.
+  attemptLink = code => {
+    return new Promise((resolve, reject) => {
+      const channel = this.socket.channel(`link:${code}`, { timeout: LINK_ACTION_TIMEOUT });
+      let finished = false;
+
+      generateKeys().then(({ publicKeyString, privateKey }) => {
+        channel.on("presence_state", state => {
+          const numOccupants = Object.keys(state).length;
+
+          if (numOccupants === 1) {
+            // Great, only sender is in topic, request link
+            channel.push("link_request", {
+              reply_to_session_id: this.socket.params.session_id,
+              public_key: publicKeyString
+            });
+
+            setTimeout(() => {
+              if (finished) return;
+              channel.leave();
+              reject(new Error("no_response"));
+            }, LINK_ACTION_TIMEOUT);
+          } else if (numOccupants === 0) {
+            // Nobody in this channel, probably a bad code.
+            channel.leave();
+            reject(new Error("failed"));
+          } else {
+            console.warn("link code channel already has 2 or more occupants, something fishy is going on.");
+            channel.leave();
+            reject(new Error("in_use"));
+          }
+        });
+
+        channel.on("link_response", payload => {
+          finished = true;
+          channel.leave();
+
+          decryptObject(payload.public_key, privateKey, payload.data).then(resolve);
+        });
+
+        channel.join().receive("error", r => console.error(r));
+      });
+    });
+  };
+}
diff --git a/src/utils/phoenix-utils.js b/src/utils/phoenix-utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..3c20f8db96aa261e4d8dd27d727b89b499728e53
--- /dev/null
+++ b/src/utils/phoenix-utils.js
@@ -0,0 +1,20 @@
+import queryString from "query-string";
+import uuid from "uuid/v4";
+import { Socket } from "phoenix";
+
+export function connectToReticulum() {
+  const qs = queryString.parse(location.search);
+
+  const socketProtocol = qs.phx_protocol || (document.location.protocol === "https:" ? "wss:" : "ws:");
+  const [retHost, retPort] = (process.env.DEV_RETICULUM_SERVER || "").split(":");
+  const isProd = process.env.NODE_ENV === "production";
+  const socketPort = qs.phx_port || (isProd ? document.location.port : retPort) || "443";
+  const socketHost = qs.phx_host || (isProd ? document.location.hostname : retHost) || "";
+  const socketUrl = `${socketProtocol}//${socketHost}${socketPort ? `:${socketPort}` : ""}/socket`;
+  console.log(`Phoenix Socket URL: ${socketUrl}`);
+
+  const socket = new Socket(socketUrl, { params: { session_id: uuid() } });
+  socket.connect();
+
+  return socket;
+}
diff --git a/src/utils/vr-caps-detect.js b/src/utils/vr-caps-detect.js
index 5f312c23b7a20ca33f7f2d88fc9bd192d90df609..79c425da26428b227daa71890b443f913640abc2 100644
--- a/src/utils/vr-caps-detect.js
+++ b/src/utils/vr-caps-detect.js
@@ -1,5 +1,8 @@
 const { detect } = require("detect-browser");
+import MobileDetect from "mobile-detect";
+
 const browser = detect();
+const mobiledetect = new MobileDetect(navigator.userAgent);
 
 // Precision on device detection is fuzzy -- we can sometimes know if a device is definitely
 // available, or definitely *not* available, and assume it may be available otherwise.
@@ -39,12 +42,16 @@ const GENERIC_ENTRY_TYPE_DEVICE_BLACKLIST = [/cardboard/i];
 // - gearvr: Oculus GearVR
 //
 export async function getAvailableVREntryTypes() {
-  const isWebVRCapableBrowser = !!navigator.getVRDisplays;
   const isSamsungBrowser = browser.name === "chrome" && navigator.userAgent.match(/SamsungBrowser/);
   const isOculusBrowser = navigator.userAgent.match(/Oculus/);
+
+  // This needs to be kept up-to-date with the latest browsers that can support VR and Hubs.
+  // Checking for navigator.getVRDisplays always passes b/c of polyfill.
+  const isWebVRCapableBrowser = window.hasNativeWebVRImplementation;
+
   const isDaydreamCapableBrowser = !!(isWebVRCapableBrowser && browser.name === "chrome" && !isSamsungBrowser);
 
-  let generic = VR_DEVICE_AVAILABILITY.no;
+  let generic = mobiledetect.mobile() ? VR_DEVICE_AVAILABILITY.no : VR_DEVICE_AVAILABILITY.maybe;
   let cardboard = VR_DEVICE_AVAILABILITY.no;
 
   // We only consider GearVR support as "maybe" and never "yes". The only browser
diff --git a/webpack.config.js b/webpack.config.js
index b77adbc4c7b14e8a1acd5ed4dfa0ba271c143734..17fbb4f8b666a03a7d1577f07d011040b6125f40 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -78,6 +78,7 @@ const config = {
   entry: {
     index: path.join(__dirname, "src", "index.js"),
     hub: path.join(__dirname, "src", "hub.js"),
+    link: path.join(__dirname, "src", "link.js"),
     "avatar-selector": path.join(__dirname, "src", "avatar-selector.js")
   },
   output: {
@@ -194,6 +195,11 @@ const config = {
       chunks: ["hub"],
       inject: "head"
     }),
+    new HTMLWebpackPlugin({
+      filename: "link.html",
+      template: path.join(__dirname, "src", "link.html"),
+      chunks: ["link"]
+    }),
     new HTMLWebpackPlugin({
       filename: "avatar-selector.html",
       template: path.join(__dirname, "src", "avatar-selector.html"),