diff --git a/README.md b/README.md
index 663780144020b403e34a5f5ab71f9de7e0a07573..d5f75a62d2caa98e27e121c428e6f51de21898f3 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,11 @@ npm start
 Then go to [http://localhost:8601/](http://localhost:8601/) - the playground outputs the default GUI component
 
 ## Developing alongside other Scratch repositories
+
+### Linking this code to another project's `node_modules/scratch-gui`
+
+#### Configuration
+
 If you wish to develop scratch-gui alongside other scratch repositories that depend on it, you may wish
 to have the other repositories use your local scratch-gui build instead of fetching the current production
 version of the scratch-gui that is found by default using `npm install`.
@@ -43,7 +48,7 @@ To do this:
 
 Instead of `BUILD_MODE=dist npm run build` you can also use `BUILD_MODE=dist npm run watch`, however this may be unreliable.
 
-### Oh no! It didn't work!
+#### Oh no! It didn't work!
 * Follow the recipe above step by step and don't change the order. It is especially important to run npm first because installing after the linking will reset the linking.
 * Make sure the repositories are siblings on your machine's file tree.
 * If you have multiple Terminal tabs or windows open for the different Scratch repositories, make sure to use the same node version in all of them.
diff --git a/package.json b/package.json
index 233c56ebed9254ca3ad2adc334e00d84988fa21e..a50e24b003e456f18deabebadc406cbc33ec942d 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
     "babel-preset-react": "^6.22.0",
     "base64-loader": "1.0.0",
     "bowser": "1.9.4",
-    "chromedriver": "2.42.0",
+    "chromedriver": "2.42.1",
     "classnames": "2.2.6",
     "copy-webpack-plugin": "^4.5.1",
     "core-js": "2.5.7",
@@ -74,6 +74,7 @@
     "postcss-loader": "^3.0.0",
     "postcss-simple-vars": "^5.0.1",
     "prop-types": "^15.5.10",
+    "query-string": "^5.1.1",
     "raf": "^3.4.0",
     "raw-loader": "^0.5.1",
     "react": "16.2.0",
@@ -96,13 +97,13 @@
     "redux-throttle": "0.1.1",
     "rimraf": "^2.6.1",
     "scratch-audio": "0.1.0-prerelease.20180625202813",
-    "scratch-blocks": "0.1.0-prerelease.1539092006",
-    "scratch-l10n": "3.0.20181004141631",
+    "scratch-blocks": "0.1.0-prerelease.1539267627",
+    "scratch-l10n": "3.0.20181010220115",
     "scratch-paint": "0.2.0-prerelease.20181010194950",
     "scratch-render": "0.1.0-prerelease.20181002192350",
     "scratch-storage": "1.0.4",
     "scratch-svg-renderer": "0.2.0-prerelease.20180926143036",
-    "scratch-vm": "0.2.0-prerelease.20181010193639",
+    "scratch-vm": "0.2.0-prerelease.20181011142759",
     "selenium-webdriver": "3.6.0",
     "startaudiocontext": "1.2.1",
     "style-loader": "^0.23.0",
diff --git a/src/components/cards/cards.jsx b/src/components/cards/cards.jsx
index 24e3d6edd94624f64fb6d52b2dc5f6e4635d6801..335849e9e4ee72b357c3253d19b2eb8dfc33ccba 100644
--- a/src/components/cards/cards.jsx
+++ b/src/components/cards/cards.jsx
@@ -64,12 +64,18 @@ const VideoStep = ({video, dragging}) => (
         ) : null}
         <iframe
             allowFullScreen
-            allow="autoplay; encrypted-media"
+            allowTransparency="true"
             frameBorder="0"
-            height="337"
-            src={`${video}?rel=0&amp;showinfo=0`}
+            height="338"
+            scrolling="no"
+            src={`https://fast.wistia.net/embed/iframe/${video}?seo=false&videoFoam=true`}
+            title="📹"
             width="600"
         />
+        <script
+            async
+            src="https://fast.wistia.net/assets/external/E-v1.js"
+        />
     </div>
 );
 
diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css
index 16f79ea4d684308cf8a6095bb4246b7966eb87af..a10b08b60a4d34e30ae137cfa4f274ab559fa6ff 100644
--- a/src/components/gui/gui.css
+++ b/src/components/gui/gui.css
@@ -279,6 +279,12 @@ $fade-out-distance: 15px;
     margin-top: 0;
 }
 
+/* Menu */
+
+.menu-bar-position {
+    position: relative;
+    z-index: $z-index-menu-bar;
+}
 /* Alerts */
 
 .alerts-container {
diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx
index 9cdafaa8cfb31143e74cf879ffcfaef8a0d7e6ca..d39e2283320384f35a81e0185c6d7d01f59fbe92 100644
--- a/src/components/gui/gui.jsx
+++ b/src/components/gui/gui.jsx
@@ -164,6 +164,7 @@ const GUIComponent = props => {
                 ) : null}
                 <MenuBar
                     accountNavOpen={accountNavOpen}
+                    className={styles.menuBarPosition}
                     enableCommunity={enableCommunity}
                     renderLogin={renderLogin}
                     onClickAccountNav={onClickAccountNav}
diff --git a/src/components/language-selector/language-selector.css b/src/components/language-selector/language-selector.css
index 25b9951608e138250d158906fcd56833a3790348..553183f77d031ec89d30c7673027e174792d351f 100644
--- a/src/components/language-selector/language-selector.css
+++ b/src/components/language-selector/language-selector.css
@@ -1,10 +1,6 @@
 @import "../../css/colors.css";
 @import "../../css/units.css";
 
-.language-icon {
-    height:  1.5rem;
-}
-
 /* Position the language select over the language icon, and make it transparent */
 .language-select {
     position: absolute;
diff --git a/src/components/menu-bar/menu-bar.css b/src/components/menu-bar/menu-bar.css
index dcc230672499634335229e1e3295bd626319970a..0e01803b57ab6f388d1e349197da6d3c7e2995d7 100644
--- a/src/components/menu-bar/menu-bar.css
+++ b/src/components/menu-bar/menu-bar.css
@@ -47,10 +47,11 @@
 
 .language-icon {
     height:  1.5rem;
+    vertical-align: middle;
 }
 
 .language-caret {
-    margin-bottom: .625rem;
+    margin: 0 .125rem;
 }
 
 .language-menu {
@@ -100,6 +101,10 @@
     padding: 0 0.75rem;
 }
 
+.menu-bar-item.language-menu {
+    padding: 0 0.5rem;
+}
+
 .menu-bar-menu {
     margin-top: $menu-bar-height;
     z-index: $z-index-menu-bar;
diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx
index 7d321ede9ecb4c068e1fc2c8a0de36af2ff6cc9a..81715e607114e540fce495bbbd7e3f06288930f9 100644
--- a/src/components/menu-bar/menu-bar.jsx
+++ b/src/components/menu-bar/menu-bar.jsx
@@ -10,18 +10,24 @@ import Button from '../button/button.jsx';
 import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx';
 import Divider from '../divider/divider.jsx';
 import LanguageSelector from '../../containers/language-selector.jsx';
-import ProjectLoader from '../../containers/project-loader.jsx';
+import SBFileUploader from '../../containers/sb-file-uploader.jsx';
 import MenuBarMenu from './menu-bar-menu.jsx';
 import {MenuItem, MenuSection} from '../menu/menu.jsx';
 import ProjectTitleInput from './project-title-input.jsx';
 import AccountNav from '../../containers/account-nav.jsx';
 import LoginDropdown from './login-dropdown.jsx';
-import ProjectSaver from '../../containers/project-saver.jsx';
+import SB3Downloader from '../../containers/sb3-downloader.jsx';
 import DeletionRestorer from '../../containers/deletion-restorer.jsx';
 import TurboMode from '../../containers/turbo-mode.jsx';
 
 import {openTipsLibrary} from '../../reducers/modals';
 import {setPlayer} from '../../reducers/mode';
+import {
+    getIsUpdating,
+    getIsShowingProject,
+    requestNewProject,
+    saveProject
+} from '../../reducers/project-state';
 import {
     openAccountMenu,
     closeAccountMenu,
@@ -123,42 +129,50 @@ class MenuBar extends React.Component {
     constructor (props) {
         super(props);
         bindAll(this, [
+            'handleClickNew',
+            'handleClickSave',
+            'handleCloseFileMenuAndThen',
             'handleLanguageMouseUp',
             'handleRestoreOption',
-            'handleCloseFileMenuAndThen',
             'restoreOptionMessage'
         ]);
-        this.state = {projectSaveInProgress: false};
     }
-    handleLanguageMouseUp (e) {
-        if (!this.props.languageMenuOpen) {
-            this.props.onClickLanguage(e);
+    componentDidUpdate (prevProps) {
+        // if we're no longer showing the project (loading, or whatever), close menus
+        if (this.props.isShowingProject && !prevProps.isShowingProject) {
+            this.props.onRequestCloseFile();
+            this.props.onRequestCloseEdit();
+        }
+    }
+    handleClickNew () {
+        const canSave = this.props.canUpdateProject; // logged in
+        // if canSave===true, it's safe to replace current project, since we will auto-save first
+        const readyToReplaceProject =
+            canSave || confirm('Replace contents of the current project?'); // eslint-disable-line no-alert
+        if (readyToReplaceProject) {
+            this.props.onClickNew(canSave);
         }
     }
+    handleClickSave () {
+        this.props.onClickSave();
+    }
     handleRestoreOption (restoreFun) {
         return () => {
             restoreFun();
             this.props.onRequestCloseEdit();
         };
     }
-    handleUpdateProject (updateFun) {
-        return () => {
-            this.props.onRequestCloseFile();
-            this.setState({projectSaveInProgress: true},
-                () => {
-                    updateFun().then(() => {
-                        this.setState({projectSaveInProgress: false});
-                    });
-                }
-            );
-        };
-    }
     handleCloseFileMenuAndThen (fn) {
         return () => {
             this.props.onRequestCloseFile();
             fn();
         };
     }
+    handleLanguageMouseUp (e) {
+        if (!this.props.languageMenuOpen) {
+            this.props.onClickLanguage(e);
+        }
+    }
     restoreOptionMessage (deletedItem) {
         switch (deletedItem) {
         case 'Sprite':
@@ -196,6 +210,13 @@ class MenuBar extends React.Component {
                 id="gui.menuBar.saveNow"
             />
         );
+        const newProjectMessage = (
+            <FormattedMessage
+                defaultMessage="New"
+                description="Menu bar item for creating a new project"
+                id="gui.menuBar.new"
+            />
+        );
         const shareButton = (
             <Button
                 className={classNames(styles.shareButton)}
@@ -210,9 +231,11 @@ class MenuBar extends React.Component {
         );
         return (
             <Box
-                className={classNames(styles.menuBar, {
-                    [styles.saveInProgress]: this.state.projectSaveInProgress
-                })}
+                className={classNames(
+                    this.props.className,
+                    styles.menuBar,
+                    {[styles.saveInProgress]: this.props.isUpdating}
+                )}
             >
                 <div className={styles.mainMenu}>
                     <div className={styles.fileGroup}>
@@ -262,33 +285,35 @@ class MenuBar extends React.Component {
                                 place={this.props.isRtl ? 'left' : 'right'}
                                 onRequestClose={this.props.onRequestCloseFile}
                             >
-                                <MenuItemTooltip
-                                    id="new"
-                                    isRtl={this.props.isRtl}
-                                >
-                                    <MenuItem>
-                                        <FormattedMessage
-                                            defaultMessage="New"
-                                            description="Menu bar item for creating a new project"
-                                            id="gui.menuBar.new"
-                                        />
+                                {/* for now, only enable New when there is no session */}
+                                {this.props.sessionExists ? (
+                                    <MenuItemTooltip
+                                        id="new"
+                                        isRtl={this.props.isRtl}
+                                    >
+                                        <MenuItem>{newProjectMessage}</MenuItem>
+                                    </MenuItemTooltip>
+                                ) : (
+                                    <MenuItem
+                                        isRtl={this.props.isRtl}
+                                        onClick={this.handleClickNew}
+                                    >
+                                        {newProjectMessage}
                                     </MenuItem>
-                                </MenuItemTooltip>
+                                )}
                                 <MenuSection>
-                                    <ProjectSaver>{(saveProject, updateProject) => (
-                                        this.props.canUpdateProject ? (
-                                            <MenuItem onClick={this.handleUpdateProject(updateProject)}>
-                                                {saveNowMessage}
-                                            </MenuItem>
-                                        ) : (
-                                            <MenuItemTooltip
-                                                id="save"
-                                                isRtl={this.props.isRtl}
-                                            >
-                                                <MenuItem>{saveNowMessage}</MenuItem>
-                                            </MenuItemTooltip>
-                                        )
-                                    )}</ProjectSaver>
+                                    {this.props.canUpdateProject ? (
+                                        <MenuItem onClick={this.handleClickSave}>
+                                            {saveNowMessage}
+                                        </MenuItem>
+                                    ) : (
+                                        <MenuItemTooltip
+                                            id="save"
+                                            isRtl={this.props.isRtl}
+                                        >
+                                            <MenuItem>{saveNowMessage}</MenuItem>
+                                        </MenuItemTooltip>
+                                    )}
                                     <MenuItemTooltip
                                         id="copy"
                                         isRtl={this.props.isRtl}
@@ -303,22 +328,25 @@ class MenuBar extends React.Component {
                                     </MenuItemTooltip>
                                 </MenuSection>
                                 <MenuSection>
-                                    <ProjectLoader>{(renderFileInput, loadProject, loadProps) => (
-                                        <MenuItem
-                                            onClick={loadProject}
-                                            {...loadProps}
-                                        >
-                                            <FormattedMessage
-                                                defaultMessage="Load from your computer"
-                                                description="Menu bar item for uploading a project from your computer"
-                                                id="gui.menuBar.uploadFromComputer"
-                                            />
-                                            {renderFileInput()}
-                                        </MenuItem>
-                                    )}</ProjectLoader>
-                                    <ProjectSaver>{saveProject => (
+                                    <SBFileUploader>
+                                        {(renderFileInput, loadProject) => (
+                                            <MenuItem
+                                                onClick={loadProject}
+                                            >
+                                                <FormattedMessage
+                                                    defaultMessage="Load from your computer"
+                                                    description={
+                                                        'Menu bar item for uploading a project from your computer'
+                                                    }
+                                                    id="gui.menuBar.uploadFromComputer"
+                                                />
+                                                {renderFileInput()}
+                                            </MenuItem>
+                                        )}
+                                    </SBFileUploader>
+                                    <SB3Downloader>{downloadProject => (
                                         <MenuItem
-                                            onClick={this.handleCloseFileMenuAndThen(saveProject)}
+                                            onClick={this.handleCloseFileMenuAndThen(downloadProject)}
                                         >
                                             <FormattedMessage
                                                 defaultMessage="Save to your computer"
@@ -326,7 +354,7 @@ class MenuBar extends React.Component {
                                                 id="gui.menuBar.downloadToComputer"
                                             />
                                         </MenuItem>
-                                    )}</ProjectSaver>
+                                    )}</SB3Downloader>
                                 </MenuSection>
                             </MenuBarMenu>
                         </div>
@@ -588,11 +616,14 @@ class MenuBar extends React.Component {
 MenuBar.propTypes = {
     accountMenuOpen: PropTypes.bool,
     canUpdateProject: PropTypes.bool,
+    className: PropTypes.string,
     editMenuOpen: PropTypes.bool,
     enableCommunity: PropTypes.bool,
     fileMenuOpen: PropTypes.bool,
     intl: intlShape,
     isRtl: PropTypes.bool,
+    isShowingProject: PropTypes.bool,
+    isUpdating: PropTypes.bool,
     languageMenuOpen: PropTypes.bool,
     loginMenuOpen: PropTypes.bool,
     onClickAccount: PropTypes.func,
@@ -600,6 +631,8 @@ MenuBar.propTypes = {
     onClickFile: PropTypes.func,
     onClickLanguage: PropTypes.func,
     onClickLogin: PropTypes.func,
+    onClickNew: PropTypes.func,
+    onClickSave: PropTypes.func,
     onLogOut: PropTypes.func,
     onOpenRegistration: PropTypes.func,
     onOpenTipLibrary: PropTypes.func,
@@ -609,25 +642,32 @@ MenuBar.propTypes = {
     onRequestCloseLanguage: PropTypes.func,
     onRequestCloseLogin: PropTypes.func,
     onSeeCommunity: PropTypes.func,
+    onShare: PropTypes.func,
     onToggleLoginOpen: PropTypes.func,
     onUpdateProjectTitle: PropTypes.func,
     renderLogin: PropTypes.func,
     sessionExists: PropTypes.bool,
+    startSaving: PropTypes.func,
     username: PropTypes.string
 };
 
-const mapStateToProps = state => ({
-    canUpdateProject: typeof (state.session && state.session.session && state.session.session.user) !== 'undefined',
-    accountMenuOpen: accountMenuOpen(state),
-    fileMenuOpen: fileMenuOpen(state),
-    editMenuOpen: editMenuOpen(state),
-    isRtl: state.locales.isRtl,
-    languageMenuOpen: languageMenuOpen(state),
-    loginMenuOpen: loginMenuOpen(state),
-    sessionExists: state.session && typeof state.session.session !== 'undefined',
-    username: state.session && state.session.session && state.session.session.user ?
-        state.session.session.user.username : null
-});
+const mapStateToProps = state => {
+    const loadingState = state.scratchGui.projectState.loadingState;
+    const user = state.session && state.session.session && state.session.session.user;
+    return {
+        accountMenuOpen: accountMenuOpen(state),
+        canUpdateProject: typeof user !== 'undefined',
+        fileMenuOpen: fileMenuOpen(state),
+        editMenuOpen: editMenuOpen(state),
+        isRtl: state.locales.isRtl,
+        isUpdating: getIsUpdating(loadingState),
+        isShowingProject: getIsShowingProject(loadingState),
+        languageMenuOpen: languageMenuOpen(state),
+        loginMenuOpen: loginMenuOpen(state),
+        sessionExists: state.session && typeof state.session.session !== 'undefined',
+        username: user ? user.username : null
+    };
+};
 
 const mapDispatchToProps = dispatch => ({
     onOpenTipLibrary: () => dispatch(openTipsLibrary()),
@@ -641,6 +681,8 @@ const mapDispatchToProps = dispatch => ({
     onRequestCloseLanguage: () => dispatch(closeLanguageMenu()),
     onClickLogin: () => dispatch(openLoginMenu()),
     onRequestCloseLogin: () => dispatch(closeLoginMenu()),
+    onClickNew: canSave => dispatch(requestNewProject(canSave)),
+    onClickSave: () => dispatch(saveProject()),
     onSeeCommunity: () => dispatch(setPlayer(true))
 });
 
diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx
index ff594d0f1184cd461d044351025b31b83a7c29eb..d8a719cb5f7af3481916e05fa83830b6b2dcce9c 100644
--- a/src/components/sprite-selector/sprite-selector.jsx
+++ b/src/components/sprite-selector/sprite-selector.jsx
@@ -7,7 +7,7 @@ import SpriteInfo from '../../containers/sprite-info.jsx';
 import SpriteList from './sprite-list.jsx';
 import ActionMenu from '../action-menu/action-menu.jsx';
 import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants';
-import {rtlLocales} from '../../lib/locale-utils';
+import {isRtl} from 'scratch-l10n';
 
 import styles from './sprite-selector.css';
 
@@ -140,7 +140,7 @@ const SpriteSelectorComponent = function (props) {
                     }
                 ]}
                 title={intl.formatMessage(messages.addSpriteFromLibrary)}
-                tooltipPlace={rtlLocales.indexOf(intl.locale) === -1 ? 'left' : 'right'}
+                tooltipPlace={isRtl(intl.locale) ? 'left' : 'right'}
                 onClick={onNewSpriteClick}
             />
         </Box>
diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx
index bf759bb1b89eade4eb1f4b01446faeecfe075c81..57c91e3c665b342ae5b951136fe459044a2c6a30 100644
--- a/src/containers/blocks.jsx
+++ b/src/containers/blocks.jsx
@@ -21,9 +21,14 @@ import {STAGE_DISPLAY_SIZES} from '../lib/layout-constants';
 import {connect} from 'react-redux';
 import {updateToolbox} from '../reducers/toolbox';
 import {activateColorPicker} from '../reducers/color-picker';
-import {closeExtensionLibrary} from '../reducers/modals';
+import {closeExtensionLibrary, openSoundRecorder} from '../reducers/modals';
 import {activateCustomProcedures, deactivateCustomProcedures} from '../reducers/custom-procedures';
 
+import {
+    activateTab,
+    SOUNDS_TAB_INDEX
+} from '../reducers/editor-tab';
+
 const addFunctionListener = (object, property, callback) => {
     const oldFn = object[property];
     object[property] = function () {
@@ -44,6 +49,7 @@ class Blocks extends React.Component {
             'handleConnectionModalStart',
             'handleConnectionModalClose',
             'handleStatusButtonUpdate',
+            'handleOpenSoundRecorder',
             'handlePromptStart',
             'handlePromptCallback',
             'handlePromptClose',
@@ -63,6 +69,8 @@ class Blocks extends React.Component {
         ]);
         this.ScratchBlocks.prompt = this.handlePromptStart;
         this.ScratchBlocks.statusButtonCallback = this.handleConnectionModalStart;
+        this.ScratchBlocks.recordSoundCallback = this.handleOpenSoundRecorder;
+
         this.state = {
             workspaceMetrics: {},
             prompt: null,
@@ -395,6 +403,9 @@ class Blocks extends React.Component {
     handleStatusButtonUpdate () {
         this.ScratchBlocks.refreshStatusButtons(this.workspace);
     }
+    handleOpenSoundRecorder () {
+        this.props.onOpenSoundRecorder();
+    }
     handlePromptCallback (input, optionSelection) {
         this.state.prompt.callback(
             input,
@@ -423,6 +434,7 @@ class Blocks extends React.Component {
             isRtl,
             isVisible,
             onActivateColorPicker,
+            onOpenSoundRecorder,
             updateToolboxState,
             onActivateCustomProcedures,
             onRequestCloseExtensionLibrary,
@@ -486,6 +498,7 @@ Blocks.propTypes = {
     messages: PropTypes.objectOf(PropTypes.string),
     onActivateColorPicker: PropTypes.func,
     onActivateCustomProcedures: PropTypes.func,
+    onOpenSoundRecorder: PropTypes.func,
     onRequestCloseCustomProcedures: PropTypes.func,
     onRequestCloseExtensionLibrary: PropTypes.func,
     options: PropTypes.shape({
@@ -565,6 +578,10 @@ const mapStateToProps = state => ({
 const mapDispatchToProps = dispatch => ({
     onActivateColorPicker: callback => dispatch(activateColorPicker(callback)),
     onActivateCustomProcedures: (data, callback) => dispatch(activateCustomProcedures(data, callback)),
+    onOpenSoundRecorder: () => {
+        dispatch(activateTab(SOUNDS_TAB_INDEX));
+        dispatch(openSoundRecorder());
+    },
     onRequestCloseExtensionLibrary: () => {
         dispatch(closeExtensionLibrary());
     },
diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx
index 2c4c55be25a66bd4db6f011d6f908dc79c24a042..49b29672da7f6a9c459520bd62fecce3fe8d7d91 100644
--- a/src/containers/gui.jsx
+++ b/src/containers/gui.jsx
@@ -1,10 +1,9 @@
-import AudioEngine from 'scratch-audio';
 import PropTypes from 'prop-types';
 import React from 'react';
-import VM from 'scratch-vm';
+import {compose} from 'redux';
 import {connect} from 'react-redux';
 import ReactModal from 'react-modal';
-import bindAll from 'lodash.bindall';
+import VM from 'scratch-vm';
 
 import ErrorBoundaryHOC from '../lib/error-boundary-hoc.jsx';
 import {openExtensionLibrary} from '../reducers/modals';
@@ -21,117 +20,49 @@ import {
     closeBackdropLibrary
 } from '../reducers/modals';
 
-import ProjectLoaderHOC from '../lib/project-loader-hoc.jsx';
+import FontLoaderHOC from '../lib/font-loader-hoc.jsx';
+import ProjectFetcherHOC from '../lib/project-fetcher-hoc.jsx';
+import ProjectSaverHOC from '../lib/project-saver-hoc.jsx';
 import vmListenerHOC from '../lib/vm-listener-hoc.jsx';
+import vmManagerHOC from '../lib/vm-manager-hoc.jsx';
 
 import GUIComponent from '../components/gui/gui.jsx';
 
 class GUI extends React.Component {
-    constructor (props) {
-        super(props);
-        bindAll(this, [
-            'loadProject'
-        ]);
-        this.state = {
-            loading: !props.vm.initialized,
-            loadingError: false,
-            errorMessage: ''
-        };
-    }
     componentDidMount () {
         if (this.props.projectTitle) {
             this.props.onUpdateReduxProjectTitle(this.props.projectTitle);
         }
-
-        if (this.props.vm.initialized) return;
-        this.audioEngine = new AudioEngine();
-        this.props.vm.attachAudioEngine(this.audioEngine);
-        this.props.vm.initialized = true;
-
-        const getFontPromises = () => {
-            const fontPromises = [];
-            // Browsers that support the font loader interface have an iterable document.fonts.values()
-            // Firefox has a mocked out object that doesn't actually implement iterable, which is why
-            // the deep safety check is necessary.
-            if (document.fonts &&
-                typeof document.fonts.values === 'function' &&
-                typeof document.fonts.values()[Symbol.iterator] === 'function') {
-                for (const fontFace of document.fonts.values()) {
-                    fontPromises.push(fontFace.loaded);
-                    fontFace.load();
-                }
-            }
-            return fontPromises;
-        };
-
-        // Font promises must be gathered after the document is loaded, because on Mac Chrome, the promise
-        // objects get replaced and the old ones never resolve.
-        if (document.readyState === 'complete') {
-            Promise.all(getFontPromises()).then(this.loadProject);
-        } else {
-            document.onreadystatechange = () => {
-                if (document.readyState !== 'complete') return;
-                document.onreadystatechange = null;
-                Promise.all(getFontPromises()).then(this.loadProject);
-            };
-        }
     }
     componentWillReceiveProps (nextProps) {
-        if (this.props.projectData !== nextProps.projectData) {
-            this.setState({loading: true}, () => {
-                this.props.vm.loadProject(nextProps.projectData)
-                    .then(() => {
-                        this.setState({loading: false});
-                    })
-                    .catch(e => {
-                        // Need to catch this error and update component state so that
-                        // error page gets rendered if project failed to load
-                        this.setState({loadingError: true, errorMessage: e});
-                    });
-            });
-        }
         if (this.props.projectTitle !== nextProps.projectTitle) {
             this.props.onUpdateReduxProjectTitle(nextProps.projectTitle);
         }
     }
-    loadProject () {
-        return this.props.vm.loadProject(this.props.projectData)
-            .then(() => {
-                this.setState({loading: false}, () => {
-                    this.props.vm.setCompatibilityMode(true);
-                    this.props.vm.start();
-                });
-            })
-            .catch(e => {
-                // Need to catch this error and update component state so that
-                // error page gets rendered if project failed to load
-                this.setState({loadingError: true, errorMessage: e});
-            });
-    }
     render () {
-        if (this.state.loadingError) {
+        if (this.props.loadingError) {
             throw new Error(
-                `Failed to load project from server [id=${window.location.hash}]: ${this.state.errorMessage}`);
+                `Failed to load project from server [id=${window.location.hash}]: ${this.props.errorMessage}`);
         }
         const {
             /* eslint-disable no-unused-vars */
             assetHost,
+            errorMessage,
             hideIntro,
+            loadingError,
             onUpdateReduxProjectTitle,
-            projectData,
             projectHost,
             projectTitle,
             /* eslint-enable no-unused-vars */
             children,
             fetchingProject,
+            isLoading,
             loadingStateVisible,
-            vm,
             ...componentProps
         } = this.props;
         return (
             <GUIComponent
-                loading={fetchingProject || this.state.loading || loadingStateVisible}
-                vm={vm}
+                loading={fetchingProject || isLoading || loadingStateVisible}
                 {...componentProps}
             >
                 {children}
@@ -143,18 +74,21 @@ class GUI extends React.Component {
 GUI.propTypes = {
     assetHost: PropTypes.string,
     children: PropTypes.node,
+    errorMessage: PropTypes.string,
     fetchingProject: PropTypes.bool,
     hideIntro: PropTypes.bool,
     importInfoVisible: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    loadingError: PropTypes.bool,
     loadingStateVisible: PropTypes.bool,
+    onChangeProjectInfo: PropTypes.func,
     onSeeCommunity: PropTypes.func,
     onUpdateProjectTitle: PropTypes.func,
     onUpdateReduxProjectTitle: PropTypes.func,
     previewInfoVisible: PropTypes.bool,
-    projectData: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
     projectHost: PropTypes.string,
     projectTitle: PropTypes.string,
-    vm: PropTypes.instanceOf(VM)
+    vm: PropTypes.instanceOf(VM).isRequired
 };
 
 const mapStateToProps = (state, ownProps) => ({
@@ -175,7 +109,8 @@ const mapStateToProps = (state, ownProps) => ({
         state.scratchGui.targets.stage.id === state.scratchGui.targets.editingTarget
     ),
     soundsTabVisible: state.scratchGui.editorTab.activeTabIndex === SOUNDS_TAB_INDEX,
-    tipsLibraryVisible: state.scratchGui.modals.tipsLibrary
+    tipsLibraryVisible: state.scratchGui.modals.tipsLibrary,
+    vm: state.scratchGui.vm
 });
 
 const mapDispatchToProps = dispatch => ({
@@ -193,9 +128,17 @@ const ConnectedGUI = connect(
     mapDispatchToProps,
 )(GUI);
 
-const WrappedGui = ErrorBoundaryHOC('Top Level App')(
-    ProjectLoaderHOC(vmListenerHOC(ConnectedGUI))
-);
+// note that redux's 'compose' function is just being used as a general utility to make
+// the hierarchy of HOC constructor calls clearer here; it has nothing to do with redux's
+// ability to compose reducers.
+const WrappedGui = compose(
+    ErrorBoundaryHOC('Top Level App'),
+    FontLoaderHOC,
+    ProjectFetcherHOC,
+    ProjectSaverHOC,
+    vmListenerHOC,
+    vmManagerHOC
+)(ConnectedGUI);
 
 WrappedGui.setAppElement = ReactModal.setAppElement;
 export default WrappedGui;
diff --git a/src/containers/project-loader.jsx b/src/containers/sb-file-uploader.jsx
similarity index 72%
rename from src/containers/project-loader.jsx
rename to src/containers/sb-file-uploader.jsx
index 66081b7a906871cef4a5230d9264cc434c2d3dcd..a5d8c99a70078a94be55d7570087a73c80bfe636 100644
--- a/src/containers/project-loader.jsx
+++ b/src/containers/sb-file-uploader.jsx
@@ -7,6 +7,7 @@ import {defineMessages, injectIntl, intlShape} from 'react-intl';
 import analytics from '../lib/analytics';
 import log from '../lib/log';
 import {setProjectTitle} from '../reducers/project-title';
+import {LoadingStates, onLoadedProject, onProjectUploadStarted} from '../reducers/project-state';
 
 import {
     openLoadingProject,
@@ -14,20 +15,19 @@ import {
 } from '../reducers/modals';
 
 /**
- * Project loader component passes a file input, load handler and props to its child.
+ * SBFileUploader component passes a file input, load handler and props to its child.
  * It expects this child to be a function with the signature
- *     function (renderFileInput, loadProject, props) {}
+ *     function (renderFileInput, loadProject) {}
  * The component can then be used to attach project loading functionality
  * to any other component:
  *
- * <ProjectLoader>{(renderFileInput, loadProject, props) => (
+ * <SBFileUploader>{(renderFileInput, loadProject) => (
  *     <MyCoolComponent
  *         onClick={loadProject}
- *         {...props}
  *     >
  *         {renderFileInput()}
  *     </MyCoolComponent>
- * )}</ProjectLoader>
+ * )}</SBFileUploader>
  */
 
 const messages = defineMessages({
@@ -38,7 +38,7 @@ const messages = defineMessages({
     }
 });
 
-class ProjectLoader extends React.Component {
+class SBFileUploader extends React.Component {
     constructor (props) {
         super(props);
         bindAll(this, [
@@ -48,6 +48,7 @@ class ProjectLoader extends React.Component {
             'handleClick'
         ]);
     }
+    // called when user has finished selecting a file to upload
     handleChange (e) {
         // Remove the hash if any (without triggering a hash change event or a reload)
         history.replaceState({}, document.title, '.');
@@ -60,7 +61,7 @@ class ProjectLoader extends React.Component {
                     action: 'Import Project File',
                     nonInteraction: true
                 });
-                this.props.closeLoadingState();
+                this.props.onLoadingFinished(this.props.loadingState);
                 // Reset the file input after project is loaded
                 // This is necessary in case the user wants to reload a project
                 thisFileInput.value = null;
@@ -68,23 +69,26 @@ class ProjectLoader extends React.Component {
             .catch(error => {
                 log.warn(error);
                 alert(this.props.intl.formatMessage(messages.loadError)); // eslint-disable-line no-alert
-                this.props.closeLoadingState();
+                this.props.onLoadingFinished(this.props.loadingState);
                 // Reset the file input after project is loaded
                 // This is necessary in case the user wants to reload a project
                 thisFileInput.value = null;
             });
         if (thisFileInput.files) { // Don't attempt to load if no file was selected
-            this.props.openLoadingState();
+            this.props.onLoadingStarted();
             reader.readAsArrayBuffer(thisFileInput.files[0]);
+            // extract the title from the file and set it as current project title
             if (thisFileInput.files[0].name) {
                 const matches = thisFileInput.files[0].name.match(/^(.*)\.sb3$/);
                 if (matches) {
-                    this.props.onSetProjectTitle(matches[1].substring(0, 100));
+                    const truncatedProjectTitle = matches[1].substring(0, 100);
+                    this.props.onSetProjectTitle(truncatedProjectTitle);
                 }
             }
         }
     }
     handleClick () {
+        // open filesystem browsing window
         this.fileInput.click();
     }
     setFileInput (input) {
@@ -102,41 +106,39 @@ class ProjectLoader extends React.Component {
         );
     }
     render () {
-        const {
-            /* eslint-disable no-unused-vars */
-            children,
-            closeLoadingState,
-            openLoadingState,
-            vm,
-            /* eslint-enable no-unused-vars */
-            ...props
-        } = this.props;
-        return this.props.children(this.renderFileInput, this.handleClick, props);
+        return this.props.children(this.renderFileInput, this.handleClick);
     }
 }
 
-ProjectLoader.propTypes = {
+SBFileUploader.propTypes = {
     children: PropTypes.func,
-    closeLoadingState: PropTypes.func,
     intl: intlShape.isRequired,
+    loadingState: PropTypes.oneOf(LoadingStates),
+    onLoadingFinished: PropTypes.func,
+    onLoadingStarted: PropTypes.func,
     onSetProjectTitle: PropTypes.func,
-    openLoadingState: PropTypes.func,
     vm: PropTypes.shape({
         loadProject: PropTypes.func
     })
 };
-
 const mapStateToProps = state => ({
+    loadingState: state.scratchGui.projectState.loadingState,
     vm: state.scratchGui.vm
 });
 
 const mapDispatchToProps = dispatch => ({
-    closeLoadingState: () => dispatch(closeLoadingProject()),
+    onLoadingFinished: loadingState => {
+        dispatch(onLoadedProject(loadingState));
+        dispatch(closeLoadingProject());
+    },
     onSetProjectTitle: title => dispatch(setProjectTitle(title)),
-    openLoadingState: () => dispatch(openLoadingProject())
+    onLoadingStarted: () => {
+        dispatch(openLoadingProject());
+        dispatch(onProjectUploadStarted());
+    }
 });
 
 export default connect(
     mapStateToProps,
     mapDispatchToProps
-)(injectIntl(ProjectLoader));
+)(injectIntl(SBFileUploader));
diff --git a/src/containers/project-saver.jsx b/src/containers/sb3-downloader.jsx
similarity index 50%
rename from src/containers/project-saver.jsx
rename to src/containers/sb3-downloader.jsx
index c32d4b1bf0a045b6c20f9d9593cf5548aebf922b..703674c62398a0a71a2424118d9155e556583c01 100644
--- a/src/containers/project-saver.jsx
+++ b/src/containers/sb3-downloader.jsx
@@ -2,39 +2,37 @@ import bindAll from 'lodash.bindall';
 import PropTypes from 'prop-types';
 import React from 'react';
 import {connect} from 'react-redux';
-import storage from '../lib/storage';
 import {projectTitleInitialState} from '../reducers/project-title';
 
-
 /**
- * Project saver component passes a saveProject function to its child.
+ * Project saver component passes a downloadProject function to its child.
  * It expects this child to be a function with the signature
- *     function (saveProject, props) {}
+ *     function (downloadProject, props) {}
  * The component can then be used to attach project saving functionality
  * to any other component:
  *
- * <ProjectSaver>{(saveProject, props) => (
+ * <SB3Downloader>{(downloadProject, props) => (
  *     <MyCoolComponent
- *         onClick={saveProject}
+ *         onClick={downloadProject}
  *         {...props}
  *     />
- * )}</ProjectSaver>
+ * )}</SB3Downloader>
  */
-class ProjectSaver extends React.Component {
+class SB3Downloader extends React.Component {
     constructor (props) {
         super(props);
         bindAll(this, [
-            'createProject',
-            'updateProject',
-            'saveProject',
-            'doStoreProject'
+            'downloadProject'
         ]);
     }
-    saveProject () {
-        const saveLink = document.createElement('a');
-        document.body.appendChild(saveLink);
+    downloadProject () {
+        const downloadLink = document.createElement('a');
+        document.body.appendChild(downloadLink);
 
         this.props.saveProjectSb3().then(content => {
+            if (this.props.onSaveFinished) {
+                this.props.onSaveFinished();
+            }
             // Use special ms version if available to get it working on Edge.
             if (navigator.msSaveOrOpenBlob) {
                 navigator.msSaveOrOpenBlob(content, this.props.projectFilename);
@@ -42,42 +40,19 @@ class ProjectSaver extends React.Component {
             }
 
             const url = window.URL.createObjectURL(content);
-            saveLink.href = url;
-            saveLink.download = this.props.projectFilename;
-            saveLink.click();
+            downloadLink.href = url;
+            downloadLink.download = this.props.projectFilename;
+            downloadLink.click();
             window.URL.revokeObjectURL(url);
-            document.body.removeChild(saveLink);
+            document.body.removeChild(downloadLink);
         });
     }
-    doStoreProject (id) {
-        return this.props.saveProjectSb3()
-            .then(content => {
-                const assetType = storage.AssetType.Project;
-                const dataFormat = storage.DataFormat.SB3;
-                const body = new FormData();
-                body.append('sb3_file', content, 'sb3_file');
-                return storage.store(
-                    assetType,
-                    dataFormat,
-                    body,
-                    id
-                );
-            });
-    }
-    createProject () {
-        return this.doStoreProject();
-    }
-    updateProject () {
-        return this.doStoreProject(this.props.projectId);
-    }
     render () {
         const {
             children
         } = this.props;
         return children(
-            this.saveProject,
-            this.updateProject,
-            this.createProject
+            this.downloadProject
         );
     }
 }
@@ -90,20 +65,19 @@ const getProjectFilename = (curTitle, defaultTitle) => {
     return `${filenameTitle.substring(0, 100)}.sb3`;
 };
 
-ProjectSaver.propTypes = {
+SB3Downloader.propTypes = {
     children: PropTypes.func,
+    onSaveFinished: PropTypes.func,
     projectFilename: PropTypes.string,
-    projectId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
     saveProjectSb3: PropTypes.func
 };
 
 const mapStateToProps = state => ({
     saveProjectSb3: state.scratchGui.vm.saveProjectSb3.bind(state.scratchGui.vm),
-    projectFilename: getProjectFilename(state.scratchGui.projectTitle, projectTitleInitialState),
-    projectId: state.scratchGui.projectId
+    projectFilename: getProjectFilename(state.scratchGui.projectTitle, projectTitleInitialState)
 });
 
 export default connect(
     mapStateToProps,
     () => ({}) // omit dispatch prop
-)(ProjectSaver);
+)(SB3Downloader);
diff --git a/src/lib/blocks.js b/src/lib/blocks.js
index f6b78b68ee4a325c5d03701e8d484466ba856f0c..57283adbbc7033a20ab286719ce5b0b230df51a4 100644
--- a/src/lib/blocks.js
+++ b/src/lib/blocks.js
@@ -48,10 +48,15 @@ export default function (vm) {
     };
 
     const soundsMenu = function () {
+        let menu = [['', '']];
         if (vm.editingTarget && vm.editingTarget.sprite.sounds.length > 0) {
-            return vm.editingTarget.sprite.sounds.map(sound => [sound.name, sound.name]);
+            menu = vm.editingTarget.sprite.sounds.map(sound => [sound.name, sound.name]);
         }
-        return [['', '']];
+        menu.push([
+            ScratchBlocks.ScratchMsgs.translate('SOUND_RECORD', 'record...'),
+            ScratchBlocks.recordSoundCallback
+        ]);
+        return menu;
     };
 
     const costumesMenu = function () {
diff --git a/src/lib/detect-locale.js b/src/lib/detect-locale.js
index c9eaf5feccd6fb362f12288c1ac712029563140b..ad2001b548a786156c838456edface5f8e03fde7 100644
--- a/src/lib/detect-locale.js
+++ b/src/lib/detect-locale.js
@@ -3,6 +3,8 @@
  * Utility function to detect locale from the browser setting or paramenter on the URL.
  */
 
+import queryString from 'query-string';
+
 /**
  * look for language setting in the browser. Check against supported locales.
  * If there's a parameter in the URL, override the browser setting
@@ -23,13 +25,18 @@ const detectLocale = supportedLocales => {
         }
     }
 
-    if (window.location.search.indexOf('locale=') !== -1 ||
-        window.location.search.indexOf('lang=') !== -1) {
-        const urlLocale = window.location.search.match(/(?:locale|lang)=([\w-]+)/)[1].toLowerCase();
-        if (supportedLocales.includes(urlLocale)) {
-            locale = urlLocale;
-        }
+    const queryParams = queryString.parse(location.search);
+    // Flatten potential arrays and remove falsy values
+    const potentialLocales = [].concat(queryParams.locale, queryParams.lang).filter(l => l);
+    if (!potentialLocales.length) {
+        return locale;
     }
+
+    const urlLocale = potentialLocales[0].toLowerCase();
+    if (supportedLocales.includes(urlLocale)) {
+        return urlLocale;
+    }
+
     return locale;
 };
 
diff --git a/src/lib/font-loader-hoc.jsx b/src/lib/font-loader-hoc.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..cbb7856c31fb3c95c3e8990d41e2cbd21922330f
--- /dev/null
+++ b/src/lib/font-loader-hoc.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+
+/* Higher Order Component to provide behavior for loading fonts.
+ * @param {React.Component} WrappedComponent component to receive fontsLoaded prop
+ * @returns {React.Component} component with font loading behavior
+ */
+const FontLoaderHOC = function (WrappedComponent) {
+    class FontLoaderComponent extends React.Component {
+        constructor (props) {
+            super(props);
+            this.state = {
+                fontsLoaded: false
+            };
+        }
+        componentDidMount () {
+            const getFontPromises = () => {
+                const fontPromises = [];
+                // Browsers that support the font loader interface have an iterable document.fonts.values()
+                // Firefox has a mocked out object that doesn't actually implement iterable, which is why
+                // the deep safety check is necessary.
+                if (document.fonts &&
+                    typeof document.fonts.values === 'function' &&
+                    typeof document.fonts.values()[Symbol.iterator] === 'function') {
+                    for (const fontFace of document.fonts.values()) {
+                        fontPromises.push(fontFace.loaded);
+                        fontFace.load();
+                    }
+                }
+                return fontPromises;
+            };
+            // Font promises must be gathered after the document is loaded, because on Mac Chrome, the promise
+            // objects get replaced and the old ones never resolve.
+            if (document.readyState === 'complete') {
+                Promise.all(getFontPromises()).then(() => {
+                    this.setState({fontsLoaded: true});
+                });
+            } else {
+                document.onreadystatechange = () => {
+                    if (document.readyState !== 'complete') return;
+                    document.onreadystatechange = null;
+                    Promise.all(getFontPromises()).then(() => {
+                        this.setState({fontsLoaded: true});
+                    });
+                };
+            }
+        }
+        render () {
+            return (
+                <WrappedComponent
+                    fontsLoaded={this.state.fontsLoaded}
+                    {...this.props}
+                />
+            );
+        }
+    }
+    return FontLoaderComponent;
+};
+
+export {
+    FontLoaderHOC as default
+};
diff --git a/src/lib/hash-parser-hoc.jsx b/src/lib/hash-parser-hoc.jsx
index b5dedeeac0eac27964ab955b29883e6e7e8f2755..2cb90a526c5e73dfc422e813a5742584a40b1bbb 100644
--- a/src/lib/hash-parser-hoc.jsx
+++ b/src/lib/hash-parser-hoc.jsx
@@ -1,9 +1,17 @@
-import React from 'react';
 import bindAll from 'lodash.bindall';
+import React from 'react';
+import PropTypes from 'prop-types';
+import {connect} from 'react-redux';
+
+import {
+    defaultProjectId,
+    getIsFetchingWithoutId,
+    setProjectId
+} from '../reducers/project-state';
 
 /* Higher Order Component to get the project id from location.hash
- * @param {React.Component} WrappedComponent component to receive projectData prop
- * @returns {React.Component} component with project loading behavior
+ * @param {React.Component} WrappedComponent: component to render
+ * @returns {React.Component} component with hash parsing behavior
  */
 const HashParserHOC = function (WrappedComponent) {
     class HashParserComponent extends React.Component {
@@ -13,35 +21,73 @@ const HashParserHOC = function (WrappedComponent) {
                 'handleHashChange'
             ]);
             this.state = {
-                projectId: null
+                hideIntro: false
             };
         }
         componentDidMount () {
             window.addEventListener('hashchange', this.handleHashChange);
             this.handleHashChange();
         }
+        componentDidUpdate (prevProps) {
+            // if we are newly fetching a non-hash project...
+            if (this.props.isFetchingWithoutId && !prevProps.isFetchingWithoutId) {
+                // ...clear the hash from the url
+                history.pushState('new-project', 'new-project',
+                    window.location.pathname + window.location.search);
+            }
+        }
         componentWillUnmount () {
             window.removeEventListener('hashchange', this.handleHashChange);
         }
         handleHashChange () {
             const hashMatch = window.location.hash.match(/#(\d+)/);
-            const projectId = hashMatch === null ? 0 : hashMatch[1];
-            if (projectId !== this.state.projectId) {
-                this.setState({projectId: projectId});
+            const hashProjectId = hashMatch === null ? defaultProjectId : hashMatch[1];
+            this.props.setProjectId(hashProjectId);
+            if (hashProjectId !== defaultProjectId) {
+                this.setState({hideIntro: true});
             }
         }
         render () {
+            const {
+                /* eslint-disable no-unused-vars */
+                isFetchingWithoutId: isFetchingWithoutIdProp,
+                reduxProjectId,
+                setProjectId: setProjectIdProp,
+                /* eslint-enable no-unused-vars */
+                ...componentProps
+            } = this.props;
             return (
                 <WrappedComponent
-                    hideIntro={this.state.projectId && this.state.projectId !== 0}
-                    projectId={this.state.projectId}
-                    {...this.props}
+                    hideIntro={this.state.hideIntro}
+                    {...componentProps}
                 />
             );
         }
     }
-
-    return HashParserComponent;
+    HashParserComponent.propTypes = {
+        isFetchingWithoutId: PropTypes.bool,
+        reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+        setProjectId: PropTypes.func
+    };
+    const mapStateToProps = state => {
+        const loadingState = state.scratchGui.projectState.loadingState;
+        return {
+            isFetchingWithoutId: getIsFetchingWithoutId(loadingState),
+            reduxProjectId: state.scratchGui.projectState.projectId
+        };
+    };
+    const mapDispatchToProps = dispatch => ({
+        setProjectId: projectId => dispatch(setProjectId(projectId))
+    });
+    // Allow incoming props to override redux-provided props. Used to mock in tests.
+    const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign(
+        {}, stateProps, dispatchProps, ownProps
+    );
+    return connect(
+        mapStateToProps,
+        mapDispatchToProps,
+        mergeProps
+    )(HashParserComponent);
 };
 
 export {
diff --git a/src/lib/libraries/decks/index.jsx b/src/lib/libraries/decks/index.jsx
index f8324f3ae73068221fe0b86bc68b5eb0757a9db5..bf714af9363415696ad2f0012cde29c16f4796e8 100644
--- a/src/lib/libraries/decks/index.jsx
+++ b/src/lib/libraries/decks/index.jsx
@@ -74,7 +74,7 @@ export default {
 
         img: libraryIntro,
         steps: [{
-            video: 'https://www.youtube.com/embed/h9x8IPGN3SI'
+            video: 'rpjvs3v9gj'
         }, {
             title: (
                 <FormattedMessage
@@ -112,7 +112,7 @@ export default {
         ),
         img: libraryAnimate,
         steps: [{
-            video: 'https://www.youtube.com/embed/RUih6RnEdPg'
+            video: 'pyur30ho05'
         }, {
             title: (
                 <FormattedMessage
@@ -186,7 +186,7 @@ export default {
         ),
         img: libraryMakeMusic,
         steps: [{
-            video: 'https://www.youtube.com/embed/UQHHAQGuhl8'
+            video: 'ir0j8ljsgm'
         },
         {
             title: (
@@ -254,7 +254,7 @@ export default {
         ),
         img: libraryMakeAGame,
         steps: [{
-            video: 'https://www.youtube.com/embed/3G2miGV4TbQ'
+            video: '5rp47ys13g'
         },
         {
             title: (
@@ -340,7 +340,7 @@ export default {
         ),
         img: libraryChaseGame,
         steps: [{
-            video: 'https://www.youtube.com/embed/IRf9-P8PiZo'
+            video: 'kusyx9thl5'
         },
         {
             title: (
@@ -471,7 +471,7 @@ export default {
         ),
         img: addBackdropThumb,
         steps: [{
-            video: 'https://www.youtube.com/embed/Xv3Z80yy2l0'
+            video: 'nict6zdzlx'
         }, {
             deckIds: [
                 'change-size',
@@ -490,7 +490,7 @@ export default {
         ),
         img: changeSizeThumb,
         steps: [{
-            video: 'https://www.youtube.com/embed/PJijGbhcT3E'
+            video: 'p8va85hh61'
         }, {
             deckIds: [
                 'glide-around',
@@ -509,7 +509,7 @@ export default {
         ),
         img: glideAroundThumb,
         steps: [{
-            video: 'https://www.youtube.com/embed/KYmbgLX1xDs'
+            video: 'sh9j978rg8'
         }, {
             deckIds: [
                 'add-a-backdrop',
@@ -529,7 +529,7 @@ export default {
         ),
         img: recordASound,
         steps: [{
-            video: 'https://www.youtube.com/embed/1WaU6e70Zig'
+            video: 'ulzl1fbzny'
         }, {
             deckIds: [
                 'Make-Music',
@@ -548,7 +548,7 @@ export default {
         ),
         img: spinThumb,
         steps: [{
-            video: 'https://www.youtube.com/embed/C76V5cuI9XM'
+            video: '07fed5hhpv'
         }, {
             deckIds: [
                 'add-a-backdrop',
@@ -567,7 +567,7 @@ export default {
         ),
         img: hideAndShowThumb,
         steps: [{
-            video: 'https://www.youtube.com/embed/6yWUvRU19ms'
+            video: 'g479ahobo9'
         }, {
             deckIds: [
                 'add-a-backdrop',
@@ -587,7 +587,7 @@ export default {
         ),
         img: switchCostumeThumb,
         steps: [{
-            video: 'https://www.youtube.com/embed/vppgw1Xiegw'
+            video: '1ocp6a1ejn'
         }, {
             deckIds: [
                 'add-a-backdrop',
@@ -607,7 +607,7 @@ export default {
         ),
         img: moveArrowKeysThumb,
         steps: [{
-            video: 'https://www.youtube.com/embed/uf6agkKnXJw'
+            video: 'yetrmk4iuu'
         }, {
             deckIds: [
                 'add-a-backdrop',
@@ -626,7 +626,7 @@ export default {
         ),
         img: addEffectsThumb,
         steps: [{
-            video: 'https://www.youtube.com/embed/w3kGWEzRtxY'
+            video: '3jvl8zgjo2'
         }, {
             deckIds: [
                 'add-a-backdrop',
diff --git a/src/lib/locale-utils.js b/src/lib/locale-utils.js
index 571186b6c0e2622f11a0269728bae585c07849d3..49ce25643c0a1467ad79c32013a88a59d07ca4ad 100644
--- a/src/lib/locale-utils.js
+++ b/src/lib/locale-utils.js
@@ -1,6 +1,7 @@
-// TODO: this probably should be coming from scratch-l10n
-// Tracking in https://github.com/LLK/scratch-l10n/issues/32
-const rtlLocales = ['he'];
+/**
+ * @fileoverview
+ * Utility functions related to localization specific to the GUI
+ */
 
 const wideLocales = [
     'ab',
@@ -16,12 +17,17 @@ const wideLocales = [
     'vi'
 ];
 
+/**
+ * Identify the languages where translations are too long to fit in fixed width parts of the gui.
+ * @param {string} locale The current locale.
+ * @return {bool} true if translations in this language are too long
+ */
+
 const isWideLocale = locale => (
     wideLocales.indexOf(locale) !== -1
 );
 
 export {
-    rtlLocales,
     wideLocales,
     isWideLocale
 };
diff --git a/src/lib/project-fetcher-hoc.jsx b/src/lib/project-fetcher-hoc.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..4d850b68109fbff61b4842f2bae3ca396718e7f1
--- /dev/null
+++ b/src/lib/project-fetcher-hoc.jsx
@@ -0,0 +1,142 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {intlShape, injectIntl} from 'react-intl';
+import bindAll from 'lodash.bindall';
+import {connect} from 'react-redux';
+
+import {
+    LoadingStates,
+    defaultProjectId,
+    onFetchedProjectData,
+    getIsFetchingWithId,
+    setProjectId
+} from '../reducers/project-state';
+
+import analytics from './analytics';
+import log from './log';
+import storage from './storage';
+
+/* Higher Order Component to provide behavior for loading projects by id. If
+ * there's no id, the default project is loaded.
+ * @param {React.Component} WrappedComponent component to receive projectData prop
+ * @returns {React.Component} component with project loading behavior
+ */
+const ProjectFetcherHOC = function (WrappedComponent) {
+    class ProjectFetcherComponent extends React.Component {
+        constructor (props) {
+            super(props);
+            bindAll(this, [
+                'fetchProject'
+            ]);
+            storage.setProjectHost(props.projectHost);
+            storage.setAssetHost(props.assetHost);
+            storage.setTranslatorFunction(props.intl.formatMessage);
+            // props.projectId might be unset, in which case we use our default;
+            // or it may be set by an even higher HOC, and passed to us.
+            // Either way, we now know what the initial projectId should be, so
+            // set it in the redux store.
+            if (
+                props.projectId !== '' &&
+                props.projectId !== null &&
+                typeof props.projectId !== 'undefined'
+            ) {
+                this.props.setProjectId(props.projectId);
+            }
+        }
+        componentDidUpdate (prevProps) {
+            if (prevProps.projectHost !== this.props.projectHost) {
+                storage.setProjectHost(this.props.projectHost);
+            }
+            if (prevProps.assetHost !== this.props.assetHost) {
+                storage.setAssetHost(this.props.assetHost);
+            }
+            if (this.props.isFetchingWithId && !prevProps.isFetchingWithId) {
+                this.fetchProject(this.props.reduxProjectId, this.props.loadingState);
+            }
+        }
+        fetchProject (projectId, loadingState) {
+            return storage
+                .load(storage.AssetType.Project, projectId, storage.DataFormat.JSON)
+                .then(projectAsset => {
+                    if (projectAsset) {
+                        this.props.onFetchedProjectData(projectAsset.data, loadingState);
+                    }
+                })
+                .then(() => {
+                    if (projectId !== defaultProjectId) {
+                        // if not default project, register a project load event
+                        analytics.event({
+                            category: 'project',
+                            action: 'Load Project',
+                            label: projectId,
+                            nonInteraction: true
+                        });
+                    }
+                })
+                .catch(err => {
+                    log.error(err);
+                });
+        }
+        render () {
+            const {
+                /* eslint-disable no-unused-vars */
+                assetHost,
+                onFetchedProjectData: onFetchedProjectDataProp,
+                intl,
+                projectHost,
+                projectId,
+                loadingState,
+                reduxProjectId,
+                setProjectId: setProjectIdProp,
+                /* eslint-enable no-unused-vars */
+                isFetchingWithId: isFetchingWithIdProp,
+                ...componentProps
+            } = this.props;
+            return (
+                <WrappedComponent
+                    fetchingProject={isFetchingWithIdProp}
+                    {...componentProps}
+                />
+            );
+        }
+    }
+    ProjectFetcherComponent.propTypes = {
+        assetHost: PropTypes.string,
+        intl: intlShape.isRequired,
+        isFetchingWithId: PropTypes.bool,
+        loadingState: PropTypes.oneOf(LoadingStates),
+        onFetchedProjectData: PropTypes.func,
+        projectHost: PropTypes.string,
+        projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+        reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+        setProjectId: PropTypes.func
+    };
+    ProjectFetcherComponent.defaultProps = {
+        assetHost: 'https://assets.scratch.mit.edu',
+        projectHost: 'https://projects.scratch.mit.edu'
+    };
+
+    const mapStateToProps = state => ({
+        isFetchingWithId: getIsFetchingWithId(state.scratchGui.projectState.loadingState),
+        loadingState: state.scratchGui.projectState.loadingState,
+        reduxProjectId: state.scratchGui.projectState.projectId
+    });
+    const mapDispatchToProps = dispatch => ({
+        onFetchedProjectData: (projectData, loadingState) =>
+            dispatch(onFetchedProjectData(projectData, loadingState)),
+        setProjectId: projectId => dispatch(setProjectId(projectId))
+    });
+    // Allow incoming props to override redux-provided props. Used to mock in tests.
+    const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign(
+        {}, stateProps, dispatchProps, ownProps
+    );
+    return injectIntl(connect(
+        mapStateToProps,
+        mapDispatchToProps,
+        mergeProps
+    )(ProjectFetcherComponent));
+};
+
+export {
+    ProjectFetcherHOC as default
+};
diff --git a/src/lib/project-loader-hoc.jsx b/src/lib/project-loader-hoc.jsx
deleted file mode 100644
index c045dc0eebe090cab651ffa903d806ebe9989a9c..0000000000000000000000000000000000000000
--- a/src/lib/project-loader-hoc.jsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import {connect} from 'react-redux';
-import {injectIntl, intlShape} from 'react-intl';
-
-import {setProjectId} from '../reducers/project-id';
-
-import analytics from './analytics';
-import log from './log';
-import storage from './storage';
-
-/* Higher Order Component to provide behavior for loading projects by id. If
- * there's no id, the default project is loaded.
- * @param {React.Component} WrappedComponent component to receive projectData prop
- * @returns {React.Component} component with project loading behavior
- */
-const ProjectLoaderHOC = function (WrappedComponent) {
-    class ProjectLoaderComponent extends React.Component {
-        constructor (props) {
-            super(props);
-            this.updateProject = this.updateProject.bind(this);
-            this.state = {
-                projectData: null,
-                fetchingProject: false
-            };
-            storage.setProjectHost(props.projectHost);
-            storage.setAssetHost(props.assetHost);
-            storage.setTranslatorFunction(props.intl.formatMessage);
-            props.setProjectId(props.projectId);
-            if (
-                props.projectId !== '' &&
-                props.projectId !== null &&
-                typeof props.projectId !== 'undefined'
-            ) {
-                this.updateProject(props.projectId);
-            }
-        }
-        componentWillUpdate (nextProps) {
-            if (this.props.projectHost !== nextProps.projectHost) {
-                storage.setProjectHost(nextProps.projectHost);
-            }
-            if (this.props.assetHost !== nextProps.assetHost) {
-                storage.setAssetHost(nextProps.assetHost);
-            }
-            if (this.props.projectId !== nextProps.projectId) {
-                this.props.setProjectId(nextProps.projectId);
-                this.setState({fetchingProject: true}, () => {
-                    this.updateProject(nextProps.projectId);
-                });
-            }
-        }
-        updateProject (projectId) {
-            storage
-                .load(storage.AssetType.Project, projectId, storage.DataFormat.JSON)
-                .then(projectAsset => projectAsset && this.setState({
-                    projectData: projectAsset.data,
-                    fetchingProject: false
-                }))
-                .then(() => {
-                    if (projectId !== 0) {
-                        analytics.event({
-                            category: 'project',
-                            action: 'Load Project',
-                            label: projectId,
-                            nonInteraction: true
-                        });
-                    }
-                })
-                .catch(err => log.error(err));
-        }
-        render () {
-            const {
-                /* eslint-disable no-unused-vars */
-                assetHost,
-                projectHost,
-                projectId,
-                setProjectId: setProjectIdProp,
-                /* eslint-enable no-unused-vars */
-                ...componentProps
-            } = this.props;
-            if (!this.state.projectData) return null;
-            return (
-                <WrappedComponent
-                    fetchingProject={this.state.fetchingProject}
-                    projectData={this.state.projectData}
-                    {...componentProps}
-                />
-            );
-        }
-    }
-    ProjectLoaderComponent.propTypes = {
-        assetHost: PropTypes.string,
-        intl: intlShape.isRequired,
-        projectHost: PropTypes.string,
-        projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
-        setProjectId: PropTypes.func
-    };
-    ProjectLoaderComponent.defaultProps = {
-        assetHost: 'https://assets.scratch.mit.edu',
-        projectHost: 'https://projects.scratch.mit.edu',
-        projectId: 0
-    };
-
-    const mapStateToProps = () => ({});
-
-    const mapDispatchToProps = dispatch => ({
-        setProjectId: id => dispatch(setProjectId(id))
-    });
-
-    return injectIntl(connect(mapStateToProps, mapDispatchToProps)(ProjectLoaderComponent));
-};
-
-export {
-    ProjectLoaderHOC as default
-};
diff --git a/src/lib/project-saver-hoc.jsx b/src/lib/project-saver-hoc.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..065a0602abbe568af67d32f775db195683860b09
--- /dev/null
+++ b/src/lib/project-saver-hoc.jsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {connect} from 'react-redux';
+import VM from 'scratch-vm';
+
+import storage from '../lib/storage';
+import {
+    LoadingStates,
+    getIsCreating,
+    getIsUpdating,
+    onCreated,
+    onUpdated,
+    onError
+} from '../reducers/project-state';
+
+/**
+ * Higher Order Component to provide behavior for saving projects.
+ * @param {React.Component} WrappedComponent the component to add project saving functionality to
+ * @returns {React.Component} WrappedComponent with project saving functionality added
+ *
+ * <ProjectSaverHOC>
+ *     <WrappedComponent />
+ * </ProjectSaverHOC>
+ */
+const ProjectSaverHOC = function (WrappedComponent) {
+    class ProjectSaverComponent extends React.Component {
+        componentDidUpdate (prevProps) {
+            if (this.props.isUpdating && !prevProps.isUpdating) {
+                this.storeProject(this.props.reduxProjectId)
+                    .then(() => { // eslint-disable-line no-unused-vars
+                        // there is nothing we expect to find in response that we need to check here
+                        this.props.onUpdated(this.props.loadingState);
+                    })
+                    .catch(err => {
+                        // NOTE: should throw up a notice for user
+                        this.props.onError(`Saving the project failed with error: ${err}`);
+                    });
+            }
+            if (this.props.isCreating && !prevProps.isCreating) {
+                this.storeProject()
+                    .then(response => {
+                        this.props.onCreated(response.id);
+                    })
+                    .catch(err => {
+                        // NOTE: should throw up a notice for user
+                        this.props.onError(`Creating a new project failed with error: ${err}`);
+                    });
+            }
+        }
+        /**
+         * storeProject:
+         * @param  {number|string|undefined} projectId defined value causes PUT/update; undefined causes POST/create
+         * @return {Promise} resolves with json object containing project's existing or new id
+         */
+        storeProject (projectId) {
+            return this.props.vm.saveProjectSb3()
+                .then(content => {
+                    const assetType = storage.AssetType.Project;
+                    const dataFormat = storage.DataFormat.SB3;
+                    const body = new FormData();
+                    body.append('sb3_file', content, 'sb3_file');
+                    // when id is undefined or null, storage.store as we have
+                    // configured it will create a new project with id
+                    return storage.store(
+                        assetType,
+                        dataFormat,
+                        body,
+                        projectId
+                    );
+                });
+        }
+        render () {
+            const {
+                /* eslint-disable no-unused-vars */
+                onCreated: onCreatedProp,
+                onUpdated: onUpdatedProp,
+                onError: onErrorProp,
+                isCreating: isCreatingProp,
+                isUpdating: isUpdatingProp,
+                loadingState,
+                reduxProjectId,
+                /* eslint-enable no-unused-vars */
+                ...componentProps
+            } = this.props;
+            return (
+                <WrappedComponent
+                    {...componentProps}
+                />
+            );
+        }
+    }
+    ProjectSaverComponent.propTypes = {
+        isCreating: PropTypes.bool,
+        isUpdating: PropTypes.bool,
+        loadingState: PropTypes.oneOf(LoadingStates),
+        onCreated: PropTypes.func,
+        onError: PropTypes.func,
+        onUpdated: PropTypes.func,
+        reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+        vm: PropTypes.instanceOf(VM).isRequired
+    };
+    const mapStateToProps = state => {
+        const loadingState = state.scratchGui.projectState.loadingState;
+        return {
+            isCreating: getIsCreating(loadingState),
+            isUpdating: getIsUpdating(loadingState),
+            loadingState: loadingState,
+            reduxProjectId: state.scratchGui.projectState.projectId,
+            vm: state.scratchGui.vm
+        };
+    };
+    const mapDispatchToProps = dispatch => ({
+        onCreated: projectId => dispatch(onCreated(projectId)),
+        onUpdated: (projectId, loadingState) => dispatch(onUpdated(projectId, loadingState)),
+        onError: errStr => dispatch(onError(errStr))
+    });
+    return connect(
+        mapStateToProps,
+        mapDispatchToProps
+    )(ProjectSaverComponent);
+};
+
+export {
+    ProjectSaverHOC as default
+};
diff --git a/src/lib/titled-hoc.jsx b/src/lib/titled-hoc.jsx
index 2534d96bd61ba9f4cb4db89a51e33c031f1be6c4..2cc3e59209ead4289927f52d591096a615baf7bf 100644
--- a/src/lib/titled-hoc.jsx
+++ b/src/lib/titled-hoc.jsx
@@ -1,14 +1,7 @@
 import React from 'react';
 import bindAll from 'lodash.bindall';
-import {defineMessages, intlShape, injectIntl} from 'react-intl';
-
-const messages = defineMessages({
-    defaultProjectTitle: {
-        id: 'gui.gui.defaultProjectTitle',
-        description: 'Default title for project',
-        defaultMessage: 'Scratch Project'
-    }
-});
+import {intlShape, injectIntl} from 'react-intl';
+import {defaultProjectTitleMessages} from '../reducers/project-title';
 
 /* Higher Order Component to get and set the project title
  * @param {React.Component} WrappedComponent component to receive project title related props
@@ -22,18 +15,24 @@ const TitledHOC = function (WrappedComponent) {
                 'handleUpdateProjectTitle'
             ]);
             this.state = {
-                projectTitle: this.props.intl.formatMessage(messages.defaultProjectTitle)
+                projectTitle: this.props.intl.formatMessage(defaultProjectTitleMessages.defaultProjectTitle)
             };
         }
         handleUpdateProjectTitle (newTitle) {
             this.setState({projectTitle: newTitle});
         }
         render () {
+            const {
+                /* eslint-disable no-unused-vars */
+                intl,
+                /* eslint-enable no-unused-vars */
+                ...componentProps
+            } = this.props;
             return (
                 <WrappedComponent
                     projectTitle={this.state.projectTitle}
                     onUpdateProjectTitle={this.handleUpdateProjectTitle}
-                    {...this.props}
+                    {...componentProps}
                 />
             );
         }
@@ -43,10 +42,7 @@ const TitledHOC = function (WrappedComponent) {
         intl: intlShape.isRequired
     };
 
-    // return TitledComponent;
-    const IntlTitledComponent = injectIntl(TitledComponent);
-    return IntlTitledComponent;
-
+    return injectIntl(TitledComponent);
 };
 
 export {
diff --git a/src/lib/tutorial-from-url.js b/src/lib/tutorial-from-url.js
index c627b8db7abf1a709719c12add3b6eec64f13257..3dab2f3d39595471c8df45c844cc142651f41726 100644
--- a/src/lib/tutorial-from-url.js
+++ b/src/lib/tutorial-from-url.js
@@ -5,6 +5,7 @@
 
 import tutorials from './libraries/decks/index.jsx';
 import analytics from './analytics';
+import queryString from 'query-string';
 
 /**
  * Get the tutorial id from the given numerical id (representing the
@@ -34,11 +35,14 @@ const getDeckIdFromUrlId = urlId => {
  * requested or found.
  */
 const detectTutorialId = () => {
-    if (window.location.search.indexOf('tutorial=') !== -1) {
-        const urlTutorialId = window.location.search.match(/(?:tutorial)=(\d+)/)[1];
-        if (urlTutorialId) {
-            return getDeckIdFromUrlId(Number(urlTutorialId));
-        }
+    const queryParams = queryString.parse(location.search);
+    const tutorialID = Number(
+        Array.isArray(queryParams.tutorial) ?
+            queryParams.tutorial[0] :
+            queryParams.tutorial
+    );
+    if (!isNaN(tutorialID)) {
+        return getDeckIdFromUrlId(tutorialID);
     }
     return null;
 };
diff --git a/src/lib/vm-manager-hoc.jsx b/src/lib/vm-manager-hoc.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..56bd7eabf74ddee9a6f0f714b9b12a25a45ef2f0
--- /dev/null
+++ b/src/lib/vm-manager-hoc.jsx
@@ -0,0 +1,120 @@
+import bindAll from 'lodash.bindall';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {connect} from 'react-redux';
+
+import VM from 'scratch-vm';
+import AudioEngine from 'scratch-audio';
+
+import {
+    LoadingStates,
+    onLoadedProject,
+    getIsLoadingWithId
+} from '../reducers/project-state';
+
+/*
+ * Higher Order Component to manage events emitted by the VM
+ * @param {React.Component} WrappedComponent component to manage VM events for
+ * @returns {React.Component} connected component with vm events bound to redux
+ */
+const vmManagerHOC = function (WrappedComponent) {
+    class VMManager extends React.Component {
+        constructor (props) {
+            super(props);
+            bindAll(this, [
+                'loadProject'
+            ]);
+            this.state = {
+                loadingError: false,
+                errorMessage: ''
+            };
+        }
+        componentDidMount () {
+            if (this.props.vm.initialized) return;
+            this.audioEngine = new AudioEngine();
+            this.props.vm.attachAudioEngine(this.audioEngine);
+            this.props.vm.setCompatibilityMode(true);
+            this.props.vm.start();
+            this.props.vm.initialized = true;
+        }
+        componentDidUpdate (prevProps) {
+            // if project is in loading state, AND fonts are loaded,
+            // and they weren't both that way until now... load project!
+            if (this.props.isLoadingWithId && this.props.fontsLoaded &&
+                (!prevProps.isLoadingWithId || !prevProps.fontsLoaded)) {
+                this.loadProject(this.props.projectData, this.props.loadingState);
+            }
+        }
+        loadProject (projectData, loadingState) {
+            return this.props.vm.loadProject(projectData)
+                .then(() => {
+                    this.props.onLoadedProject(loadingState);
+                })
+                .catch(e => {
+                    // Need to catch this error and update component state so that
+                    // error page gets rendered if project failed to load
+                    this.setState({loadingError: true, errorMessage: e});
+                });
+        }
+        render () {
+            const {
+                /* eslint-disable no-unused-vars */
+                fontsLoaded,
+                onLoadedProject: onLoadedProjectProp,
+                projectData,
+                projectId,
+                loadingState,
+                /* eslint-enable no-unused-vars */
+                isLoadingWithId: isLoadingWithIdProp,
+                vm,
+                ...componentProps
+            } = this.props;
+            return (
+                <WrappedComponent
+                    errorMessage={this.state.errorMessage}
+                    isLoading={isLoadingWithIdProp}
+                    loadingError={this.state.loadingError}
+                    vm={vm}
+                    {...componentProps}
+                />
+            );
+        }
+    }
+
+    VMManager.propTypes = {
+        fontsLoaded: PropTypes.bool,
+        isLoadingWithId: PropTypes.bool,
+        loadingState: PropTypes.oneOf(LoadingStates),
+        onLoadedProject: PropTypes.func,
+        projectData: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
+        projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+        vm: PropTypes.instanceOf(VM).isRequired
+    };
+
+    const mapStateToProps = state => {
+        const loadingState = state.scratchGui.projectState.loadingState;
+        return {
+            isLoadingWithId: getIsLoadingWithId(loadingState),
+            projectData: state.scratchGui.projectState.projectData,
+            projectId: state.scratchGui.projectState.projectId,
+            loadingState: loadingState
+        };
+    };
+
+    const mapDispatchToProps = dispatch => ({
+        onLoadedProject: loadingState => dispatch(onLoadedProject(loadingState))
+    });
+
+    // Allow incoming props to override redux-provided props. Used to mock in tests.
+    const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign(
+        {}, stateProps, dispatchProps, ownProps
+    );
+
+    return connect(
+        mapStateToProps,
+        mapDispatchToProps,
+        mergeProps
+    )(VMManager);
+};
+
+export default vmManagerHOC;
diff --git a/src/playground/blocks-only.jsx b/src/playground/blocks-only.jsx
index 1a72e267b8776201ecf8cf9bda733142636087ce..d08e733378bcab352cd9b448237d50069ec32009 100644
--- a/src/playground/blocks-only.jsx
+++ b/src/playground/blocks-only.jsx
@@ -27,7 +27,7 @@ const BlocksOnly = props => (
     </GUI>
 );
 
-const App = HashParserHOC(AppStateHOC(BlocksOnly));
+const App = AppStateHOC(HashParserHOC(BlocksOnly));
 
 const appTarget = document.createElement('div');
 document.body.appendChild(appTarget);
diff --git a/src/playground/compatibility-testing.jsx b/src/playground/compatibility-testing.jsx
index 3209131c3f7a943b65757979b49f2b096011947a..e5977a8d2dfc1c2a196886c4ffaa9084fd1a54e6 100644
--- a/src/playground/compatibility-testing.jsx
+++ b/src/playground/compatibility-testing.jsx
@@ -4,7 +4,7 @@ import ReactDOM from 'react-dom';
 import GUI from '../containers/gui.jsx';
 import HashParserHOC from '../lib/hash-parser-hoc.jsx';
 import AppStateHOC from '../lib/app-state-hoc.jsx';
-const WrappedGui = HashParserHOC(AppStateHOC(GUI));
+const WrappedGui = AppStateHOC(HashParserHOC(GUI));
 
 
 const DEFAULT_PROJECT_ID = '10015059';
diff --git a/src/playground/player.jsx b/src/playground/player.jsx
index e236de667eb4b12919548908daad377dfa827fe9..2235bbac703d01ebfe4003976a3ce65919b3eb95 100644
--- a/src/playground/player.jsx
+++ b/src/playground/player.jsx
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import {connect} from 'react-redux';
+import {compose} from 'redux';
 
 import Box from '../components/box/box.jsx';
 import GUI from '../containers/gui.jsx';
@@ -48,8 +49,19 @@ const mapDispatchToProps = dispatch => ({
     onSeeInside: () => dispatch(setPlayer(false))
 });
 
-const ConnectedPlayer = connect(mapStateToProps, mapDispatchToProps)(Player);
-const WrappedPlayer = HashParserHOC(AppStateHOC(TitledHOC(ConnectedPlayer)));
+const ConnectedPlayer = connect(
+    mapStateToProps,
+    mapDispatchToProps
+)(Player);
+
+// note that redux's 'compose' function is just being used as a general utility to make
+// the hierarchy of HOC constructor calls clearer here; it has nothing to do with redux's
+// ability to compose reducers.
+const WrappedPlayer = compose(
+    AppStateHOC,
+    HashParserHOC,
+    TitledHOC
+)(ConnectedPlayer);
 
 const appTarget = document.createElement('div');
 document.body.appendChild(appTarget);
diff --git a/src/playground/render-gui.jsx b/src/playground/render-gui.jsx
index 562e57eac9c4c60ad4aea322adeec8b907067761..c5be243d113a20c53e99d0cd3861fd6ebe640911 100644
--- a/src/playground/render-gui.jsx
+++ b/src/playground/render-gui.jsx
@@ -1,5 +1,6 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
+import {compose} from 'redux';
 
 import AppStateHOC from '../lib/app-state-hoc.jsx';
 import GUI from '../containers/gui.jsx';
@@ -13,7 +14,15 @@ import TitledHOC from '../lib/titled-hoc.jsx';
  */
 export default appTarget => {
     GUI.setAppElement(appTarget);
-    const WrappedGui = HashParserHOC(AppStateHOC(TitledHOC(GUI)));
+
+    // note that redux's 'compose' function is just being used as a general utility to make
+    // the hierarchy of HOC constructor calls clearer here; it has nothing to do with redux's
+    // ability to compose reducers.
+    const WrappedGui = compose(
+        AppStateHOC,
+        HashParserHOC,
+        TitledHOC
+    )(GUI);
 
     // TODO a hack for testing the backpack, allow backpack host to be set by url param
     const backpackHostMatches = window.location.href.match(/[?&]backpack_host=([^&]*)&?/);
diff --git a/src/reducers/gui.js b/src/reducers/gui.js
index bce112a1c327c6e6a2761cbbeb6e63f29dbf87da..1eb254b47718b4a600d4bac4b8163e67af25b2ff 100644
--- a/src/reducers/gui.js
+++ b/src/reducers/gui.js
@@ -13,7 +13,7 @@ import modalReducer, {modalsInitialState} from './modals';
 import modeReducer, {modeInitialState} from './mode';
 import monitorReducer, {monitorsInitialState} from './monitors';
 import monitorLayoutReducer, {monitorLayoutInitialState} from './monitor-layout';
-import projectIdReducer, {projectIdInitialState} from './project-id';
+import projectStateReducer, {projectStateInitialState} from './project-state';
 import projectTitleReducer, {projectTitleInitialState} from './project-title';
 import restoreDeletionReducer, {restoreDeletionInitialState} from './restore-deletion';
 import stageSizeReducer, {stageSizeInitialState} from './stage-size';
@@ -43,7 +43,7 @@ const guiInitialState = {
     modals: modalsInitialState,
     monitors: monitorsInitialState,
     monitorLayout: monitorLayoutInitialState,
-    projectId: projectIdInitialState,
+    projectState: projectStateInitialState,
     projectTitle: projectTitleInitialState,
     restoreDeletion: restoreDeletionInitialState,
     targets: targetsInitialState,
@@ -110,7 +110,7 @@ const guiReducer = combineReducers({
     modals: modalReducer,
     monitors: monitorReducer,
     monitorLayout: monitorLayoutReducer,
-    projectId: projectIdReducer,
+    projectState: projectStateReducer,
     projectTitle: projectTitleReducer,
     restoreDeletion: restoreDeletionReducer,
     targets: targetReducer,
diff --git a/src/reducers/locales.js b/src/reducers/locales.js
index e2b7fed6ece50df70689f08fbb871139aed325c9..34a42163eda0a869133e76bd8f3fa37d6e1de140 100644
--- a/src/reducers/locales.js
+++ b/src/reducers/locales.js
@@ -2,7 +2,7 @@ import {addLocaleData} from 'react-intl';
 
 import {localeData} from 'scratch-l10n';
 import editorMessages from 'scratch-l10n/locales/editor-msgs';
-import {rtlLocales} from '../lib/locale-utils';
+import {isRtl} from 'scratch-l10n';
 
 addLocaleData(localeData);
 
@@ -21,7 +21,7 @@ const reducer = function (state, action) {
     switch (action.type) {
     case SELECT_LOCALE:
         return Object.assign({}, state, {
-            isRtl: rtlLocales.indexOf(action.locale) !== -1,
+            isRtl: isRtl(action.locale),
             locale: action.locale,
             messagesByLocale: state.messagesByLocale,
             messages: state.messagesByLocale[action.locale]
@@ -57,7 +57,7 @@ const initLocale = function (currentState, locale) {
             {},
             currentState,
             {
-                isRtl: rtlLocales.indexOf(locale) !== -1,
+                isRtl: isRtl(locale),
                 locale: locale,
                 messagesByLocale: currentState.messagesByLocale,
                 messages: currentState.messagesByLocale[locale]
diff --git a/src/reducers/project-id.js b/src/reducers/project-id.js
deleted file mode 100644
index 9045873c74e67c04b06281f133ab215f1a8f4c27..0000000000000000000000000000000000000000
--- a/src/reducers/project-id.js
+++ /dev/null
@@ -1,27 +0,0 @@
-const SET_PROJECT_ID = 'scratch-gui/project-id/SET_PROJECT_ID';
-
-const initialState = null;
-
-const reducer = function (state, action) {
-    if (typeof state === 'undefined') state = initialState;
-
-    switch (action.type) {
-    case SET_PROJECT_ID:
-        return action.id;
-    default:
-        return state;
-    }
-};
-
-const setProjectId = function (id) {
-    return {
-        type: SET_PROJECT_ID,
-        id: id
-    };
-};
-
-export {
-    reducer as default,
-    initialState as projectIdInitialState,
-    setProjectId
-};
diff --git a/src/reducers/project-state.js b/src/reducers/project-state.js
new file mode 100644
index 0000000000000000000000000000000000000000..b69cfc5244d93c4fa59e493dc5d47ed61417cfba
--- /dev/null
+++ b/src/reducers/project-state.js
@@ -0,0 +1,344 @@
+import keyMirror from 'keymirror';
+
+const DONE_CREATING_NEW = 'scratch-gui/project-state/DONE_CREATING_NEW';
+const DONE_FETCHING_WITH_ID = 'scratch-gui/project-state/DONE_FETCHING_WITH_ID';
+const DONE_FETCHING_DEFAULT = 'scratch-gui/project-state/DONE_FETCHING_DEFAULT';
+const DONE_FETCHING_DEFAULT_TO_SAVE = 'scratch-gui/project-state/DONE_FETCHING_DEFAULT_TO_SAVE';
+const DONE_LOADING_VM_WITH_ID = 'scratch-gui/project-state/DONE_LOADING_VM_WITH_ID';
+const DONE_LOADING_VM_NEW_DEFAULT = 'scratch-gui/project-state/DONE_LOADING_VM_NEW_DEFAULT';
+const DONE_LOADING_VM_NEW_DEFAULT_TO_SAVE = 'scratch-gui/project-state/DONE_LOADING_VM_NEW_DEFAULT_TO_SAVE';
+const DONE_LOADING_VM_FILE_UPLOAD = 'scratch-gui/project-state/DONE_LOADING_VM_FILE_UPLOAD';
+const DONE_SAVING_WITH_ID = 'scratch-gui/project-state/DONE_SAVING_WITH_ID';
+const DONE_SAVING_WITH_ID_BEFORE_NEW = 'scratch-gui/project-state/DONE_SAVING_WITH_ID_BEFORE_NEW';
+const GO_TO_ERROR_STATE = 'scratch-gui/project-state/GO_TO_ERROR_STATE';
+const SET_PROJECT_ID = 'scratch-gui/project-state/SET_PROJECT_ID';
+const START_FETCHING_NEW_WITHOUT_SAVING = 'scratch-gui/project-state/START_FETCHING_NEW_WITHOUT_SAVING';
+const START_LOADING_VM_FILE_UPLOAD = 'scratch-gui/project-state/START_LOADING_FILE_UPLOAD';
+const START_SAVING = 'scratch-gui/project-state/START_SAVING';
+const START_SAVING_BEFORE_CREATING_NEW = 'scratch-gui/project-state/START_SAVING_BEFORE_CREATING_NEW';
+
+const defaultProjectId = 0; // hardcoded id of default project
+
+const LoadingState = keyMirror({
+    NOT_LOADED: null,
+    ERROR: null,
+    FETCHING_WITH_ID: null,
+    FETCHING_NEW_DEFAULT: null,
+    FETCHING_NEW_DEFAULT_TO_SAVE: null,
+    LOADING_VM_WITH_ID: null,
+    LOADING_VM_FILE_UPLOAD: null,
+    LOADING_VM_NEW_DEFAULT: null,
+    LOADING_VM_NEW_DEFAULT_TO_SAVE: null,
+    SHOWING_WITH_ID: null,
+    SHOWING_WITHOUT_ID: null,
+    SAVING_WITH_ID: null,
+    SAVING_WITH_ID_BEFORE_NEW: null,
+    CREATING_NEW: null
+});
+
+const LoadingStates = Object.keys(LoadingState);
+
+const getIsFetchingWithoutId = loadingState => (
+    // LOADING_VM_FILE_UPLOAD is an honorary fetch, since there is no fetching step for file uploads
+    loadingState === LoadingState.LOADING_VM_FILE_UPLOAD ||
+    loadingState === LoadingState.FETCHING_NEW_DEFAULT ||
+    loadingState === LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE
+);
+const getIsFetchingWithId = loadingState => (
+    loadingState === LoadingState.FETCHING_WITH_ID ||
+    loadingState === LoadingState.FETCHING_NEW_DEFAULT ||
+    loadingState === LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE
+);
+const getIsLoadingWithId = loadingState => (
+    loadingState === LoadingState.LOADING_VM_WITH_ID ||
+    loadingState === LoadingState.LOADING_VM_NEW_DEFAULT ||
+    loadingState === LoadingState.LOADING_VM_NEW_DEFAULT_TO_SAVE
+);
+const getIsCreating = loadingState => (
+    loadingState === LoadingState.CREATING_NEW
+);
+const getIsUpdating = loadingState => (
+    loadingState === LoadingState.SAVING_WITH_ID ||
+    loadingState === LoadingState.SAVING_WITH_ID_BEFORE_NEW
+);
+const getIsShowingProject = loadingState => (
+    loadingState === LoadingState.SHOWING_WITH_ID ||
+    loadingState === LoadingState.SHOWING_WITHOUT_ID
+);
+
+const initialState = {
+    errStr: null,
+    projectData: null,
+    projectId: null,
+    loadingState: LoadingState.NOT_LOADED
+};
+
+const reducer = function (state, action) {
+    if (typeof state === 'undefined') state = initialState;
+
+    switch (action.type) {
+    case DONE_CREATING_NEW:
+        // We need to set project id since we just created new project on the server.
+        // No need to load, we should have data already in vm.
+        if (state.loadingState === LoadingState.CREATING_NEW) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.SHOWING_WITH_ID,
+                id: action.id
+            });
+        }
+        return state;
+    case DONE_FETCHING_WITH_ID:
+        if (state.loadingState === LoadingState.FETCHING_WITH_ID) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.LOADING_VM_WITH_ID,
+                projectData: action.projectData
+            });
+        }
+        return state;
+    case DONE_FETCHING_DEFAULT:
+        if (state.loadingState === LoadingState.FETCHING_NEW_DEFAULT) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.LOADING_VM_NEW_DEFAULT,
+                projectData: action.projectData
+            });
+        }
+        return state;
+    case DONE_FETCHING_DEFAULT_TO_SAVE:
+        if (state.loadingState === LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.LOADING_VM_NEW_DEFAULT_TO_SAVE,
+                projectData: action.projectData
+            });
+        }
+        return state;
+    case DONE_LOADING_VM_FILE_UPLOAD:
+        // note that we don't need to explicitly set projectData, because it is loaded
+        // into the vm directly in file-loader-from-local
+        if (state.loadingState === LoadingState.LOADING_VM_FILE_UPLOAD) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.SHOWING_WITHOUT_ID
+            });
+        }
+        return state;
+    case DONE_LOADING_VM_WITH_ID:
+        if (state.loadingState === LoadingState.LOADING_VM_WITH_ID) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.SHOWING_WITH_ID
+            });
+        }
+        return state;
+    case DONE_LOADING_VM_NEW_DEFAULT:
+        if (state.loadingState === LoadingState.LOADING_VM_NEW_DEFAULT) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.SHOWING_WITHOUT_ID
+            });
+        }
+        return state;
+    case DONE_LOADING_VM_NEW_DEFAULT_TO_SAVE:
+        if (state.loadingState === LoadingState.LOADING_VM_NEW_DEFAULT_TO_SAVE) {
+            return Object.assign({}, state, {
+                // NOTE: this is set to skip over sending a POST to create the new project
+                // on the server, until we can get that working on the backend.
+                // loadingState: LoadingState.CREATING_NEW
+                loadingState: LoadingState.SHOWING_WITH_ID
+            });
+        }
+        return state;
+    case DONE_SAVING_WITH_ID:
+        if (state.loadingState === LoadingState.SAVING_WITH_ID) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.SHOWING_WITH_ID
+            });
+        }
+        return state;
+    case DONE_SAVING_WITH_ID_BEFORE_NEW:
+        if (state.loadingState === LoadingState.SAVING_WITH_ID_BEFORE_NEW) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE,
+                projectId: defaultProjectId
+            });
+        }
+        return state;
+    case SET_PROJECT_ID:
+        // if we were already showing something, only fetch project if the
+        // project id has changed. This prevents re-fetching projects unnecessarily.
+        if (state.loadingState === LoadingState.SHOWING_WITH_ID) {
+            if (state.projectId !== action.id) {
+                return Object.assign({}, state, {
+                    loadingState: LoadingState.FETCHING_WITH_ID,
+                    projectId: action.id
+                });
+            }
+        } else { // allow any other states to transition to fetching project
+            return Object.assign({}, state, {
+                loadingState: LoadingState.FETCHING_WITH_ID,
+                projectId: action.id
+            });
+        }
+        return state;
+    case START_FETCHING_NEW_WITHOUT_SAVING:
+        if ([
+            LoadingState.SHOWING_WITH_ID,
+            LoadingState.SHOWING_WITHOUT_ID
+        ].includes(state.loadingState)) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.FETCHING_NEW_DEFAULT,
+                projectId: defaultProjectId
+            });
+        }
+        return state;
+    case START_LOADING_VM_FILE_UPLOAD:
+        if ([
+            LoadingState.NOT_LOADED,
+            LoadingState.SHOWING_WITH_ID,
+            LoadingState.SHOWING_WITHOUT_ID
+        ].includes(state.loadingState)) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.LOADING_VM_FILE_UPLOAD,
+                projectId: null // clear any current projectId
+            });
+        }
+        return state;
+    case START_SAVING:
+        if (state.loadingState === LoadingState.SHOWING_WITH_ID) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.SAVING_WITH_ID
+            });
+        }
+        return state;
+    case START_SAVING_BEFORE_CREATING_NEW:
+        if (state.loadingState === LoadingState.SHOWING_WITH_ID) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.SAVING_WITH_ID_BEFORE_NEW
+            });
+        }
+        return state;
+    case GO_TO_ERROR_STATE:
+    // NOTE: we should introduce handling in components for showing ERROR state
+        if ([
+            LoadingState.NOT_LOADED,
+            LoadingState.FETCHING_WITH_ID,
+            LoadingState.FETCHING_NEW_DEFAULT,
+            LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE
+        ].includes(state.loadingState)) {
+            return Object.assign({}, state, {
+                loadingState: LoadingState.ERROR,
+                errStr: action.errStr
+            });
+        }
+        return state;
+    default:
+        return state;
+    }
+};
+
+const onCreated = id => ({
+    type: DONE_CREATING_NEW,
+    id: id
+});
+
+const onFetchedProjectData = (projectData, loadingState) => {
+    switch (loadingState) {
+    case LoadingState.FETCHING_WITH_ID:
+        return {
+            type: DONE_FETCHING_WITH_ID,
+            projectData: projectData
+        };
+    case LoadingState.FETCHING_NEW_DEFAULT:
+        return {
+            type: DONE_FETCHING_DEFAULT,
+            projectData: projectData
+        };
+    case LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE:
+        return {
+            type: DONE_FETCHING_DEFAULT_TO_SAVE,
+            projectData: projectData
+        };
+    default:
+        break;
+    }
+};
+
+const onLoadedProject = loadingState => {
+    switch (loadingState) {
+    case LoadingState.LOADING_VM_WITH_ID:
+        return {
+            type: DONE_LOADING_VM_WITH_ID
+        };
+    case LoadingState.LOADING_VM_FILE_UPLOAD:
+        return {
+            type: DONE_LOADING_VM_FILE_UPLOAD
+        };
+    case LoadingState.LOADING_VM_NEW_DEFAULT:
+        return {
+            type: DONE_LOADING_VM_NEW_DEFAULT
+        };
+    case LoadingState.LOADING_VM_NEW_DEFAULT_TO_SAVE:
+        return {
+            type: DONE_LOADING_VM_NEW_DEFAULT_TO_SAVE
+        };
+    default:
+        break;
+    }
+};
+
+const onUpdated = loadingState => {
+    switch (loadingState) {
+    case LoadingState.SAVING_WITH_ID:
+        return {
+            type: DONE_SAVING_WITH_ID
+        };
+    case LoadingState.SAVING_WITH_ID_BEFORE_NEW:
+        return {
+            type: DONE_SAVING_WITH_ID_BEFORE_NEW
+        };
+    default:
+        break;
+    }
+};
+
+const onError = errStr => ({
+    type: GO_TO_ERROR_STATE,
+    errStr: errStr
+});
+
+const setProjectId = id => ({
+    type: SET_PROJECT_ID,
+    id: id
+});
+
+const requestNewProject = canSave => {
+    if (canSave) return {type: START_SAVING_BEFORE_CREATING_NEW};
+    return {type: START_FETCHING_NEW_WITHOUT_SAVING};
+};
+
+const onProjectUploadStarted = () => ({
+    type: START_LOADING_VM_FILE_UPLOAD
+});
+
+const saveProject = () => ({
+    type: START_SAVING
+});
+
+export {
+    reducer as default,
+    initialState as projectStateInitialState,
+    LoadingState,
+    LoadingStates,
+    defaultProjectId,
+    getIsCreating,
+    getIsFetchingWithoutId,
+    getIsFetchingWithId,
+    getIsLoadingWithId,
+    getIsUpdating,
+    getIsShowingProject,
+    onCreated,
+    onError,
+    onFetchedProjectData,
+    onLoadedProject,
+    onProjectUploadStarted,
+    onUpdated,
+    requestNewProject,
+    saveProject,
+    setProjectId
+};
diff --git a/src/reducers/project-title.js b/src/reducers/project-title.js
index 09bf6c4eab417e626b8a719326ac685c25d46cdd..389687ceb10b23c8f59964abdf8b78bb48c2fb6f 100644
--- a/src/reducers/project-title.js
+++ b/src/reducers/project-title.js
@@ -1,9 +1,19 @@
+import {defineMessages} from 'react-intl';
+
 const SET_PROJECT_TITLE = 'projectTitle/SET_PROJECT_TITLE';
 
 // we are initializing to a blank string instead of an actual title,
 // because it would be hard to localize here
 const initialState = '';
 
+const defaultProjectTitleMessages = defineMessages({
+    defaultProjectTitle: {
+        id: 'gui.gui.defaultProjectTitle',
+        description: 'Default title for project',
+        defaultMessage: 'Scratch Project'
+    }
+});
+
 const reducer = function (state, action) {
     if (typeof state === 'undefined') state = initialState;
     switch (action.type) {
@@ -21,5 +31,6 @@ const setProjectTitle = title => ({
 export {
     reducer as default,
     initialState as projectTitleInitialState,
+    defaultProjectTitleMessages,
     setProjectTitle
 };
diff --git a/src/reducers/vm.js b/src/reducers/vm.js
index da2155cf8cd55dc52cb9f9b2d4a63e42c3ca2ff4..0ed08544463059d2adfbb18f07e1ebabaa0855ae 100644
--- a/src/reducers/vm.js
+++ b/src/reducers/vm.js
@@ -21,6 +21,7 @@ const setVM = function (vm) {
         vm: vm
     };
 };
+
 export {
     reducer as default,
     initialState as vmInitialState,
diff --git a/test/integration/blocks.test.js b/test/integration/blocks.test.js
index b3ec62e2f6042c69ccb5f7ea985d8267e73da93e..8c4e1a6037bb57d08947c0a45e1bb407a28b4ea9 100644
--- a/test/integration/blocks.test.js
+++ b/test/integration/blocks.test.js
@@ -157,4 +157,17 @@ describe('Working with the blocks', () => {
         const logs = await getLogs();
         await expect(logs).toEqual([]);
     });
+
+    test('Record option from sound block menu opens sound recorder', async () => {
+        await loadUri(uri);
+        await clickXpath('//button[@title="Try It"]');
+        await clickText('Code');
+        await clickText('Sound', scope.blocksTab);
+        await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
+        await clickText('Meow', scope.blocksTab); // Click "play sound <Meow> until done" block
+        await clickText('record'); // Click "record..." option in the block's sound menu
+        await findByText('Record Sound'); // Sound recorder is open
+        const logs = await getLogs();
+        await expect(logs).toEqual([]);
+    });
 });
diff --git a/test/unit/util/detect-locale.test.js b/test/unit/util/detect-locale.test.js
index a7a699c414900b4a42c9b0cf452ac95f06bc65e0..f9b1f63fceaa6970c8f93bbf7f44dce489a6d732 100644
--- a/test/unit/util/detect-locale.test.js
+++ b/test/unit/util/detect-locale.test.js
@@ -67,4 +67,20 @@ describe('detectLocale', () => {
         );
         expect(detectLocale(supportedLocales)).toEqual('en');
     });
+
+    test('works with an empty locale', () => {
+        Object.defineProperty(window.location,
+            'search',
+            {value: '?locale='}
+        );
+        expect(detectLocale(supportedLocales)).toEqual('en');
+    });
+
+    test('if multiple, uses the first locale', () => {
+        Object.defineProperty(window.location,
+            'search',
+            {value: '?locale=de&locale=en'}
+        );
+        expect(detectLocale(supportedLocales)).toEqual('de');
+    });
 });
diff --git a/test/unit/util/hash-project-loader-hoc.test.jsx b/test/unit/util/hash-project-loader-hoc.test.jsx
index 1242385d62241588f60b89ea9be57f169f1516fd..a78f43475f0d416ecfd2f48efbb5108b4872c3b4 100644
--- a/test/unit/util/hash-project-loader-hoc.test.jsx
+++ b/test/unit/util/hash-project-loader-hoc.test.jsx
@@ -1,42 +1,81 @@
 import React from 'react';
-import HashParserHOC from '../../../src/lib/hash-parser-hoc.jsx';
+import configureStore from 'redux-mock-store';
 import {mount} from 'enzyme';
 
+import HashParserHOC from '../../../src/lib/hash-parser-hoc.jsx';
+
 jest.mock('react-ga');
 
 describe('HashParserHOC', () => {
+    const mockStore = configureStore();
+    let store;
+
+    beforeEach(() => {
+        store = mockStore({
+            scratchGui: {
+                projectState: {}
+            }
+        });
+    });
+
     test('when there is a hash, it passes the hash as projectId', () => {
         const Component = ({projectId}) => <div>{projectId}</div>;
         const WrappedComponent = HashParserHOC(Component);
         window.location.hash = '#1234567';
-        const mounted = mount(<WrappedComponent />);
-        expect(mounted.state().projectId).toEqual('1234567');
+        const mockSetProjectIdFunc = jest.fn();
+        mount(
+            <WrappedComponent
+                setProjectId={mockSetProjectIdFunc}
+                store={store}
+            />
+        );
+        expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('1234567');
     });
 
     test('when there is no hash, it passes 0 as the projectId', () => {
         const Component = ({projectId}) => <div>{projectId}</div>;
         const WrappedComponent = HashParserHOC(Component);
         window.location.hash = '';
-        const mounted = mount(<WrappedComponent />);
-        expect(mounted.state().projectId).toEqual(0);
+        const mockSetProjectIdFunc = jest.fn();
+        mount(
+            <WrappedComponent
+                setProjectId={mockSetProjectIdFunc}
+                store={store}
+            />
+        );
+        expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe(0);
     });
 
     test('when the hash is not a number, it passes 0 as projectId', () => {
         const Component = ({projectId}) => <div>{projectId}</div>;
         const WrappedComponent = HashParserHOC(Component);
         window.location.hash = '#winning';
-        const mounted = mount(<WrappedComponent />);
-        expect(mounted.state().projectId).toEqual(0);
+        const mockSetProjectIdFunc = jest.fn();
+        mount(
+            <WrappedComponent
+                setProjectId={mockSetProjectIdFunc}
+                store={store}
+            />
+        );
+        expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe(0);
     });
 
     test('when hash change happens, the projectId state is changed', () => {
         const Component = ({projectId}) => <div>{projectId}</div>;
         const WrappedComponent = HashParserHOC(Component);
         window.location.hash = '';
-        const mounted = mount(<WrappedComponent />);
-        expect(mounted.state().projectId).toEqual(0);
+        const mockSetProjectIdFunc = jest.fn();
+        const mounted = mount(
+            <WrappedComponent
+                setProjectId={mockSetProjectIdFunc}
+                store={store}
+            />
+        );
         window.location.hash = '#1234567';
-        mounted.instance().handleHashChange();
-        expect(mounted.state().projectId).toEqual('1234567');
+        mounted
+            .childAt(0)
+            .instance()
+            .handleHashChange();
+        expect(mockSetProjectIdFunc.mock.calls.length).toBe(2);
     });
 });
diff --git a/test/unit/util/project-fetcher-hoc.test.jsx b/test/unit/util/project-fetcher-hoc.test.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..31593acc01d059b850f1194f502cabddf522e2b7
--- /dev/null
+++ b/test/unit/util/project-fetcher-hoc.test.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import configureStore from 'redux-mock-store';
+
+import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
+
+import ProjectFetcherHOC from '../../../src/lib/project-fetcher-hoc.jsx';
+import storage from '../../../src/lib/storage';
+import {LoadingState} from '../../../src/reducers/project-state';
+
+jest.mock('react-ga');
+
+describe('ProjectFetcherHOC', () => {
+    const mockStore = configureStore();
+    let store;
+
+    beforeEach(() => {
+        store = mockStore({
+            scratchGui: {
+                projectState: {}
+            }
+        });
+    });
+
+    test('when there is an id, it tries to update the store with that id', () => {
+        const Component = ({projectId}) => <div>{projectId}</div>;
+        const WrappedComponent = ProjectFetcherHOC(Component);
+        const mockSetProjectIdFunc = jest.fn();
+        mountWithIntl(
+            <WrappedComponent
+                projectId="100"
+                setProjectId={mockSetProjectIdFunc}
+                store={store}
+            />
+        );
+        expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('100');
+    });
+    test('when there is a reduxProjectId and isFetchingWithProjectId is true, it loads the project', () => {
+        const mockedOnFetchedProject = jest.fn();
+        const originalLoad = storage.load;
+        storage.load = jest.fn((type, id) => Promise.resolve({data: id}));
+        const Component = ({projectId}) => <div>{projectId}</div>;
+        const WrappedComponent = ProjectFetcherHOC(Component);
+        const mounted = mountWithIntl(
+            <WrappedComponent
+                store={store}
+                onFetchedProjectData={mockedOnFetchedProject}
+            />
+        );
+        mounted.setProps({
+            reduxProjectId: '100',
+            isFetchingWithId: true,
+            loadingState: LoadingState.FETCHING_WITH_ID
+        });
+        expect(storage.load).toHaveBeenLastCalledWith(
+            storage.AssetType.Project, '100', storage.DataFormat.JSON
+        );
+        storage.load = originalLoad;
+        // nextTick needed since storage.load is async, and onFetchedProject is called in its then()
+        process.nextTick(
+            () => expect(mockedOnFetchedProject)
+                .toHaveBeenLastCalledWith('100', LoadingState.FETCHING_WITH_ID)
+        );
+    });
+});
diff --git a/test/unit/util/project-loader-hoc.test.jsx b/test/unit/util/project-loader-hoc.test.jsx
deleted file mode 100644
index a0a51cc66101947abdfe5751ff33441c48de0579..0000000000000000000000000000000000000000
--- a/test/unit/util/project-loader-hoc.test.jsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import React from 'react';
-import configureStore from 'redux-mock-store';
-import ProjectLoaderHOC from '../../../src/lib/project-loader-hoc.jsx';
-import storage from '../../../src/lib/storage';
-import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
-
-jest.mock('react-ga');
-
-describe('ProjectLoaderHOC', () => {
-    const mockStore = configureStore();
-    let store;
-
-    beforeEach(() => {
-        store = mockStore({scratchGui: {}});
-    });
-
-    test('when there is an id, it tries to load that project', () => {
-        const Component = ({projectData}) => <div>{projectData}</div>;
-        const WrappedComponent = ProjectLoaderHOC(Component);
-        const originalLoad = storage.load;
-        storage.load = jest.fn((type, id) => Promise.resolve({data: id}));
-        const mounted = mountWithIntl(
-            <WrappedComponent
-                projectId="100"
-                store={store}
-            />
-        );
-        expect(mounted.props().projectId).toEqual('100');
-        expect(storage.load).toHaveBeenLastCalledWith(
-            storage.AssetType.Project, '100', storage.DataFormat.JSON
-        );
-        storage.load = originalLoad;
-    });
-
-    test('when there is no project data, it renders null', () => {
-        const Component = ({projectData}) => <div>{projectData}</div>;
-        const WrappedComponent = ProjectLoaderHOC(Component);
-        const originalLoad = storage.load;
-        storage.load = jest.fn(() => Promise.resolve(null));
-        const mounted = mountWithIntl(<WrappedComponent store={store} />);
-        storage.load = originalLoad;
-        const mountedDiv = mounted.find('div');
-        expect(mountedDiv.exists()).toEqual(false);
-    });
-
-});
diff --git a/test/unit/util/tutorial-from-url.test.js b/test/unit/util/tutorial-from-url.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..f29cbd8cc379436cd8d2eac9b01e44413a1ec680
--- /dev/null
+++ b/test/unit/util/tutorial-from-url.test.js
@@ -0,0 +1,40 @@
+jest.mock('../../../src/lib/analytics.js', () => ({
+    event: () => {}
+}));
+
+jest.mock('../../../src/lib/libraries/decks/index.jsx', () => ({
+    foo: {urlId: 1}
+}));
+
+import {detectTutorialId} from '../../../src/lib/tutorial-from-url.js';
+
+Object.defineProperty(
+    window.location,
+    'search',
+    {value: '', writable: true}
+);
+
+test('returns the tutorial ID if the urlId matches', () => {
+    window.location.search = '?tutorial=1';
+    expect(detectTutorialId()).toBe('foo');
+});
+
+test('returns null if no matching urlId', () => {
+    window.location.search = '?tutorial=10';
+    expect(detectTutorialId()).toBe(null);
+});
+
+test('returns null if empty template', () => {
+    window.location.search = '?tutorial=';
+    expect(detectTutorialId()).toBe(null);
+});
+
+test('returns null if non-numeric template', () => {
+    window.location.search = '?tutorial=asdf';
+    expect(detectTutorialId()).toBe(null);
+});
+
+test('takes the first of multiple', () => {
+    window.location.search = '?tutorial=1&tutorial=2';
+    expect(detectTutorialId()).toBe('foo');
+});
diff --git a/test/unit/util/vm-manager-hoc.test.jsx b/test/unit/util/vm-manager-hoc.test.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..0bcb8f4b8ea8a1d224af62494a95dd3f9f4720b0
--- /dev/null
+++ b/test/unit/util/vm-manager-hoc.test.jsx
@@ -0,0 +1,101 @@
+import 'web-audio-test-api';
+
+import React from 'react';
+import configureStore from 'redux-mock-store';
+import {mount} from 'enzyme';
+import VM from 'scratch-vm';
+import {LoadingState} from '../../../src/reducers/project-state';
+
+import vmManagerHOC from '../../../src/lib/vm-manager-hoc.jsx';
+
+describe('VMManagerHOC', () => {
+    const mockStore = configureStore();
+    let store;
+    let vm;
+
+    beforeEach(() => {
+        store = mockStore({
+            scratchGui: {
+                projectState: {}
+            }
+        });
+        vm = new VM();
+        vm.attachAudioEngine = jest.fn();
+        vm.setCompatibilityMode = jest.fn();
+        vm.start = jest.fn();
+    });
+    test('when it mounts, the vm is initialized', () => {
+        const Component = () => (<div />);
+        const WrappedComponent = vmManagerHOC(Component);
+        mount(
+            <WrappedComponent
+                store={store}
+                vm={vm}
+            />
+        );
+        expect(vm.attachAudioEngine.mock.calls.length).toBe(1);
+        expect(vm.setCompatibilityMode.mock.calls.length).toBe(1);
+        expect(vm.start.mock.calls.length).toBe(1);
+        expect(vm.initialized).toBe(true);
+    });
+    test('if it mounts with an initialized vm, it does not reinitialize the vm', () => {
+        const Component = () => <div />;
+        const WrappedComponent = vmManagerHOC(Component);
+        vm.initialized = true;
+        mount(
+            <WrappedComponent
+                store={store}
+                vm={vm}
+            />
+        );
+        expect(vm.attachAudioEngine.mock.calls.length).toBe(0);
+        expect(vm.setCompatibilityMode.mock.calls.length).toBe(0);
+        expect(vm.start.mock.calls.length).toBe(0);
+        expect(vm.initialized).toBe(true);
+    });
+    test('if the isLoadingWithId prop becomes true, it loads project data into the vm', () => {
+        vm.loadProject = jest.fn(() => Promise.resolve());
+        const mockedOnLoadedProject = jest.fn();
+        const Component = () => <div />;
+        const WrappedComponent = vmManagerHOC(Component);
+        const mounted = mount(
+            <WrappedComponent
+                isLoadingWithId={false}
+                store={store}
+                vm={vm}
+                onLoadedProject={mockedOnLoadedProject}
+            />
+        );
+        mounted.setProps({
+            isLoadingWithId: true,
+            loadingState: LoadingState.LOADING_VM_WITH_ID,
+            projectData: '100'
+        });
+        expect(vm.loadProject).toHaveBeenLastCalledWith('100');
+        // nextTick needed since vm.loadProject is async, and we have to wait for it :/
+        process.nextTick(() => expect(mockedOnLoadedProject).toHaveBeenLastCalledWith(LoadingState.LOADING_VM_WITH_ID));
+    });
+    test('if there is projectData, the child is rendered', () => {
+        const Component = () => <div />;
+        const WrappedComponent = vmManagerHOC(Component);
+        const mounted = mount(
+            <WrappedComponent
+                projectData="100"
+                store={store}
+                vm={vm}
+            />
+        );
+        expect(mounted.find('div').length).toBe(1);
+    });
+    test('if there is no projectData, nothing is rendered', () => {
+        const Component = () => <div />;
+        const WrappedComponent = vmManagerHOC(Component);
+        const mounted = mount(
+            <WrappedComponent
+                store={store}
+                vm={vm}
+            />
+        );
+        expect(mounted.find('div').length).toBe(0);
+    });
+});