diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json
index 2ad5c1cf95deb131b7b8b4aa02add9f63c11cc7f..69415d5b01c1341c48f2e36d1aa593308962ec8d 100644
--- a/src/assets/translations.data.json
+++ b/src/assets/translations.data.json
@@ -32,7 +32,10 @@
     "audio.granted-title": "Mic permissions granted",
     "audio.granted-subtitle": "You can still mute yourself in-game",
     "audio.granted-next": "NEXT",
-    "exit.subtitle": "Your session has ended. Refresh your browser to start a new one.",
+    "exit.subtitle.exited": "Your session has ended. Refresh your browser to start a new one.",
+    "exit.subtitle.closed": "This room is no longer available.",
+    "exit.subtitle.full": "This room is full, please try again later.",
+    "exit.subtitle.connect_error": "Unable to connect to this room, please try again later.",
     "autoexit.title": "Auto-ending session in ",
     "autoexit.title_units": " seconds",
     "autoexit.subtitle": "You have started another session.",
diff --git a/src/hub.js b/src/hub.js
index 6a889632adf4c22606f1503df58342312bf3b8da..89ebcc4687a8e819f281eeee9f628f3d135a1623 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -112,7 +112,6 @@ AFRAME.registerInputMappings(inputConfig, true);
 
 const store = new Store();
 const concurrentLoadDetector = new ConcurrentLoadDetector();
-const hubChannel = new HubChannel(store);
 
 concurrentLoadDetector.start();
 
@@ -124,111 +123,6 @@ if (!store.state.profile.has_changed_name) {
   store.update({ profile: { display_name: generateRandomName() } });
 }
 
-async function exitScene() {
-  if (NAF.connection.adapter && NAF.connection.adapter.localMediaStream) {
-    NAF.connection.adapter.localMediaStream.getTracks().forEach(t => t.stop());
-  }
-  hubChannel.disconnect();
-  const scene = document.querySelector("a-scene");
-  scene.renderer.animate(null); // Stop animation loop, TODO A-Frame should do this
-  document.body.removeChild(scene);
-}
-
-function applyProfileFromStore(playerRig) {
-  const displayName = store.state.profile.display_name;
-  playerRig.setAttribute("player-info", {
-    displayName,
-    avatarSrc: "#" + (store.state.profile.avatar_id || "botdefault")
-  });
-  document.querySelector("a-scene").emit("username-changed", { username: displayName });
-}
-
-async function enterScene(mediaStream, enterInVR, janusRoomId) {
-  const scene = document.querySelector("a-scene");
-  const playerRig = document.querySelector("#player-rig");
-  document.querySelector("a-scene canvas").classList.remove("blurred");
-  scene.render();
-
-  scene.setAttribute("stats-plus", false);
-
-  if (enterInVR) {
-    scene.enterVR();
-  }
-
-  AFRAME.registerInputActions(inGameActions, "default");
-
-  document.querySelector("#player-camera").setAttribute("look-controls", "");
-
-  scene.setAttribute("networked-scene", {
-    room: janusRoomId,
-    serverURL: process.env.JANUS_SERVER
-  });
-
-  if (isMobile || qsTruthy("mobile")) {
-    playerRig.setAttribute("virtual-gamepad-controls", {});
-  }
-
-  const applyProfileOnPlayerRig = applyProfileFromStore.bind(null, playerRig);
-  applyProfileOnPlayerRig();
-  store.addEventListener("statechanged", applyProfileOnPlayerRig);
-
-  const avatarScale = parseInt(qs.avatar_scale, 10);
-
-  if (avatarScale) {
-    playerRig.setAttribute("scale", { x: avatarScale, y: avatarScale, z: avatarScale });
-  }
-
-  const videoTracks = mediaStream.getVideoTracks();
-  let sharingScreen = videoTracks.length > 0;
-
-  const screenEntityId = `${NAF.clientId}-screen`;
-  let screenEntity = document.getElementById(screenEntityId);
-
-  scene.addEventListener("action_share_screen", () => {
-    sharingScreen = !sharingScreen;
-    if (sharingScreen) {
-      for (const track of videoTracks) {
-        mediaStream.addTrack(track);
-      }
-    } else {
-      for (const track of mediaStream.getVideoTracks()) {
-        mediaStream.removeTrack(track);
-      }
-    }
-    NAF.connection.adapter.setLocalMediaStream(mediaStream);
-    screenEntity.setAttribute("visible", sharingScreen);
-  });
-
-  if (!qsTruthy("offline")) {
-    document.body.addEventListener("connected", () => {
-      hubChannel.sendEntryEvent().then(() => {
-        store.update({ lastEnteredAt: moment().toJSON() });
-      });
-    });
-
-    scene.components["networked-scene"].connect();
-
-    if (mediaStream) {
-      NAF.connection.adapter.setLocalMediaStream(mediaStream);
-
-      if (screenEntity) {
-        screenEntity.setAttribute("visible", sharingScreen);
-      } else if (sharingScreen) {
-        const sceneEl = document.querySelector("a-scene");
-        screenEntity = document.createElement("a-entity");
-        screenEntity.id = screenEntityId;
-        screenEntity.setAttribute("offset-relative-to", {
-          target: "#player-camera",
-          offset: "0 0 -2",
-          on: "action_share_screen"
-        });
-        screenEntity.setAttribute("networked", { template: "#video-template" });
-        sceneEl.appendChild(screenEntity);
-      }
-    }
-  }
-}
-
 function mountUI(scene, props = {}) {
   const disableAutoExitOnConcurrentLoad = qsTruthy("allow_multi");
   const forcedVREntryType = qs.vr_entry_type || null;
@@ -240,8 +134,6 @@ function mountUI(scene, props = {}) {
     <UIRoot
       {...{
         scene,
-        enterScene,
-        exitScene,
         concurrentLoadDetector,
         disableAutoExitOnConcurrentLoad,
         forcedVREntryType,
@@ -258,19 +150,138 @@ function mountUI(scene, props = {}) {
 
 const onReady = async () => {
   const scene = document.querySelector("a-scene");
+  const hubChannel = new HubChannel(store);
+
   document.querySelector("a-scene canvas").classList.add("blurred");
   window.APP.scene = scene;
 
   registerNetworkSchemas();
 
+  let uiProps = {};
+
   mountUI(scene);
 
-  let modifiedProps = {};
   const remountUI = props => {
-    modifiedProps = { ...modifiedProps, ...props };
-    mountUI(scene, modifiedProps);
+    uiProps = { ...uiProps, ...props };
+    mountUI(scene, uiProps);
+  };
+
+  const applyProfileFromStore = playerRig => {
+    const displayName = store.state.profile.display_name;
+    playerRig.setAttribute("player-info", {
+      displayName,
+      avatarSrc: "#" + (store.state.profile.avatar_id || "botdefault")
+    });
+    document.querySelector("a-scene").emit("username-changed", { username: displayName });
   };
 
+  const exitScene = () => {
+    if (NAF.connection.adapter && NAF.connection.adapter.localMediaStream) {
+      NAF.connection.adapter.localMediaStream.getTracks().forEach(t => t.stop());
+    }
+    hubChannel.disconnect();
+    const scene = document.querySelector("a-scene");
+    scene.renderer.animate(null); // Stop animation loop, TODO A-Frame should do this
+    document.body.removeChild(scene);
+  };
+
+  const enterScene = async (mediaStream, enterInVR, janusRoomId) => {
+    const scene = document.querySelector("a-scene");
+    const playerRig = document.querySelector("#player-rig");
+    document.querySelector("a-scene canvas").classList.remove("blurred");
+    scene.render();
+
+    if (enterInVR) {
+      scene.enterVR();
+    }
+
+    AFRAME.registerInputActions(inGameActions, "default");
+
+    document.querySelector("#player-camera").setAttribute("look-controls", "");
+
+    scene.setAttribute("networked-scene", {
+      room: janusRoomId,
+      serverURL: process.env.JANUS_SERVER
+    });
+
+    if (!qsTruthy("no_stats")) {
+      scene.setAttribute("stats", true);
+    }
+
+    if (isMobile || qsTruthy("mobile")) {
+      playerRig.setAttribute("virtual-gamepad-controls", {});
+    }
+
+    const applyProfileOnPlayerRig = applyProfileFromStore.bind(null, playerRig);
+    applyProfileOnPlayerRig();
+    store.addEventListener("statechanged", applyProfileOnPlayerRig);
+
+    const avatarScale = parseInt(qs.avatar_scale, 10);
+
+    if (avatarScale) {
+      playerRig.setAttribute("scale", { x: avatarScale, y: avatarScale, z: avatarScale });
+    }
+
+    const videoTracks = mediaStream.getVideoTracks();
+    let sharingScreen = videoTracks.length > 0;
+
+    const screenEntityId = `${NAF.clientId}-screen`;
+    let screenEntity = document.getElementById(screenEntityId);
+
+    scene.addEventListener("action_share_screen", () => {
+      sharingScreen = !sharingScreen;
+      if (sharingScreen) {
+        for (const track of videoTracks) {
+          mediaStream.addTrack(track);
+        }
+      } else {
+        for (const track of mediaStream.getVideoTracks()) {
+          mediaStream.removeTrack(track);
+        }
+      }
+      NAF.connection.adapter.setLocalMediaStream(mediaStream);
+      screenEntity.setAttribute("visible", sharingScreen);
+    });
+
+    if (!qsTruthy("offline")) {
+      document.body.addEventListener("connected", () => {
+        hubChannel.sendEntryEvent().then(() => {
+          store.update({ lastEnteredAt: moment().toJSON() });
+        });
+      });
+
+      scene.components["networked-scene"].connect().catch(connectError => {
+        // hacky until we get return codes
+        const isFull = connectError.error && connectError.error.msg.match(/\bfull\b/i);
+        remountUI({ roomUnavailableReason: isFull ? "full" : "connect_error" });
+        exitScene();
+
+        return;
+      });
+
+      if (mediaStream) {
+        NAF.connection.adapter.setLocalMediaStream(mediaStream);
+
+        if (screenEntity) {
+          screenEntity.setAttribute("visible", sharingScreen);
+        } else if (sharingScreen) {
+          const sceneEl = document.querySelector("a-scene");
+          screenEntity = document.createElement("a-entity");
+          screenEntity.id = screenEntityId;
+          screenEntity.setAttribute("offset-relative-to", {
+            target: "#player-camera",
+            offset: "0 0 -2",
+            on: "action_share_screen"
+          });
+          screenEntity.setAttribute("networked", { template: "#video-template" });
+          sceneEl.appendChild(screenEntity);
+        }
+      }
+    }
+  };
+
+  remountUI({ enterScene, exitScene });
+
   getAvailableVREntryTypes().then(availableVREntryTypes => {
     remountUI({ availableVREntryTypes });
   });
@@ -325,7 +336,14 @@ const onReady = async () => {
       initialEnvironmentEl.setAttribute("gltf-bundle", `src: ${gltfBundleUrl}`);
       hubChannel.setPhoenixChannel(channel);
     })
-    .receive("error", res => console.error(res));
+    .receive("error", res => {
+      if (res.reason === "closed") {
+        exitScene();
+        remountUI({ roomUnavailableReason: "closed" });
+      }
+
+      console.error(res);
+    });
 };
 
 document.addEventListener("DOMContentLoaded", onReady);
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index 0734bdf6163937d74a86f3b0500d96df058e87a2..e7bc7c81ed66f84a86d9b650191008ae1510b273 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -66,7 +66,8 @@ class UIRoot extends Component {
     availableVREntryTypes: PropTypes.object,
     initialEnvironmentLoaded: PropTypes.bool,
     janusRoomId: PropTypes.number,
-    hubName: PropTypes.string
+    hubName: PropTypes.string,
+    roomUnavailableReason: PropTypes.string
   };
 
   state = {
@@ -507,33 +508,35 @@ class UIRoot extends Component {
   };
 
   render() {
-    if (!this.props.initialEnvironmentLoaded || !this.props.availableVREntryTypes || !this.props.janusRoomId) {
+    if (this.state.exited || this.props.roomUnavailableReason) {
+      const exitSubtitleId = `exit.subtitle.${this.state.exited ? "exited" : this.props.roomUnavailableReason}`;
+
       return (
         <IntlProvider locale={lang} messages={messages}>
-          <div className="loading-panel">
-            <div className="loader-wrap">
-              <div className="loader">
-                <div className="loader-center" />
-              </div>
-            </div>
+          <div className="exited-panel">
             <div className="loading-panel__title">
               <b>moz://a</b> duck
             </div>
+            <div className="loading-panel__subtitle">
+              <FormattedMessage id={exitSubtitleId} />
+            </div>
           </div>
         </IntlProvider>
       );
     }
 
-    if (this.state.exited) {
+    if (!this.props.initialEnvironmentLoaded || !this.props.availableVREntryTypes || !this.props.janusRoomId) {
       return (
         <IntlProvider locale={lang} messages={messages}>
-          <div className="exited-panel">
+          <div className="loading-panel">
+            <div className="loader-wrap">
+              <div className="loader">
+                <div className="loader-center" />
+              </div>
+            </div>
             <div className="loading-panel__title">
               <b>moz://a</b> duck
             </div>
-            <div className="loading-panel__subtitle">
-              <FormattedMessage id="exit.subtitle" />
-            </div>
           </div>
         </IntlProvider>
       );