diff --git a/package-lock.json b/package-lock.json
index f747006..d3c50e2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,6 +23,7 @@
         "is-plain-object": "^5.0.0",
         "lz-string": "^1.4.4",
         "nanoevents": "^6.0.2",
+        "unofficial-galaxy-sdk": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#1.0.1",
         "vite": "^2.9.12",
         "vite-plugin-pwa": "^0.12.0",
         "vite-tsconfig-paths": "^3.5.0",
@@ -6878,6 +6879,10 @@
         "node": ">= 4.0.0"
       }
     },
+    "node_modules/unofficial-galaxy-sdk": {
+      "version": "1.0",
+      "resolved": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#97d6da6636a2fc38c14aa893d4b336ccc22314af"
+    },
     "node_modules/upath": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
diff --git a/package.json b/package.json
index f0ce56b..eeeecf0 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
     "is-plain-object": "^5.0.0",
     "lz-string": "^1.4.4",
     "nanoevents": "^6.0.2",
+    "unofficial-galaxy-sdk": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#1.0.1",
     "vite": "^2.9.12",
     "vite-plugin-pwa": "^0.12.0",
     "vite-tsconfig-paths": "^3.5.0",
diff --git a/src/App.vue b/src/App.vue
index 40e21de..7e7a0aa 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -8,6 +8,7 @@
             <TPS v-if="unref(showTPS)" />
             <GameOverScreen />
             <NaNScreen />
+            <CloudSaveResolver />
             <component :is="gameComponent" />
         </div>
     </template>
@@ -16,10 +17,11 @@
 <script setup lang="tsx">
 import "@fontsource/roboto-mono";
 import Error from "components/Error.vue";
+import CloudSaveResolver from "components/saves/CloudSaveResolver.vue";
 import { jsx } from "features/feature";
 import state from "game/state";
 import { coerceComponent, render } from "util/vue";
-import { CSSProperties } from "vue";
+import type { CSSProperties } from "vue";
 import { computed, toRef, unref } from "vue";
 import Game from "./components/Game.vue";
 import GameOverScreen from "./components/GameOverScreen.vue";
diff --git a/src/components/Modal.vue b/src/components/Modal.vue
index 9fd5f06..95f3ec8 100644
--- a/src/components/Modal.vue
+++ b/src/components/Modal.vue
@@ -4,6 +4,7 @@
             name="modal"
             @before-enter="isAnimating = true"
             @after-leave="isAnimating = false"
+            appear
         >
             <div
                 class="modal-mask"
@@ -12,7 +13,7 @@
                 v-bind="$attrs"
             >
                 <div class="modal-wrapper">
-                    <div class="modal-container">
+                    <div class="modal-container" :width="width">
                         <div class="modal-header">
                             <slot name="header" :shown="isOpen"> default header </slot>
                         </div>
@@ -45,6 +46,8 @@ import Context from "./Context.vue";
 
 const _props = defineProps<{
     modelValue: boolean;
+    preventClosing?: boolean;
+    width?: string;
 }>();
 const props = toRefs(_props);
 const emit = defineEmits<{
@@ -53,7 +56,9 @@ const emit = defineEmits<{
 
 const isOpen = computed(() => unref(props.modelValue) || isAnimating.value);
 function close() {
-    emit("update:modelValue", false);
+    if (unref(props.preventClosing) !== true) {
+        emit("update:modelValue", false);
+    }
 }
 
 const isAnimating = ref(false);
diff --git a/src/components/NaNScreen.vue b/src/components/NaNScreen.vue
index f9c7b6f..9967085 100644
--- a/src/components/NaNScreen.vue
+++ b/src/components/NaNScreen.vue
@@ -55,7 +55,7 @@ import Decimal, { format } from "util/bignum";
 import type { ComponentPublicInstance } from "vue";
 import { computed, ref, toRef, watch } from "vue";
 import Toggle from "./fields/Toggle.vue";
-import SavesManager from "./SavesManager.vue";
+import SavesManager from "./saves/SavesManager.vue";
 
 const { discordName, discordLink } = projInfo;
 const autosave = ref(true);
diff --git a/src/components/Nav.vue b/src/components/Nav.vue
index c5d0026..e2cc798 100644
--- a/src/components/Nav.vue
+++ b/src/components/Nav.vue
@@ -36,7 +36,7 @@
         </div>
         <div @click="savesManager?.open()">
             <Tooltip display="Saves" :direction="Direction.Down" xoffset="-20px">
-                <span class="material-icons">library_books</span>
+                <span class="material-icons" :class="{ needsSync }">library_books</span>
             </Tooltip>
         </div>
         <div @click="options?.open()">
@@ -53,7 +53,7 @@
         </div>
         <div @click="savesManager?.open()">
             <Tooltip display="Saves" :direction="Direction.Right">
-                <span class="material-icons">library_books</span>
+                <span class="material-icons" :class="{ needsSync }">library_books</span>
             </Tooltip>
         </div>
         <div @click="options?.open()">
@@ -98,12 +98,14 @@
 import Changelog from "data/Changelog.vue";
 import projInfo from "data/projInfo.json";
 import Tooltip from "features/tooltips/Tooltip.vue";
+import settings from "game/settings";
 import { Direction } from "util/common";
+import { galaxy, syncedSaves } from "util/galaxy";
 import type { ComponentPublicInstance } from "vue";
-import { ref } from "vue";
+import { computed, ref } from "vue";
 import Info from "./Info.vue";
 import Options from "./Options.vue";
-import SavesManager from "./SavesManager.vue";
+import SavesManager from "./saves/SavesManager.vue";
 
 const info = ref<ComponentPublicInstance<typeof Info> | null>(null);
 const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
@@ -117,6 +119,10 @@ const { useHeader, banner, title, discordName, discordLink, versionNumber } = pr
 function openDiscord() {
     window.open(discordLink, "mywindow");
 }
+
+const needsSync = computed(
+    () => galaxy.value?.loggedIn === true && !syncedSaves.value.includes(settings.active)
+);
 </script>
 
 <style scoped>
@@ -264,4 +270,32 @@ function openDiscord() {
     color: var(--foreground);
     text-shadow: none;
 }
+
+.needsSync {
+    color: var(--danger);
+    animation: 4s wiggle ease infinite;
+}
+
+@keyframes wiggle {
+    0% {
+        transform: rotate(-3deg);
+        box-shadow: 0 2px 2px #0003;
+    }
+    5% {
+        transform: rotate(20deg);
+    }
+    10% {
+        transform: rotate(-15deg);
+    }
+    15% {
+        transform: rotate(5deg);
+    }
+    20% {
+        transform: rotate(-1deg);
+    }
+    25% {
+        transform: rotate(0);
+        box-shadow: 0 2px 2px #0003;
+    }
+}
 </style>
diff --git a/src/components/saves/CloudSaveResolver.vue b/src/components/saves/CloudSaveResolver.vue
new file mode 100644
index 0000000..9a2b823
--- /dev/null
+++ b/src/components/saves/CloudSaveResolver.vue
@@ -0,0 +1,228 @@
+<template>
+    <Modal v-model="isOpen" width="960px" ref="modal" :prevent-closing="true">
+        <template v-slot:header>
+            <div class="cloud-saves-modal-header">
+                <h2>Cloud {{ pluralizedSave }} loaded!</h2>
+            </div>
+        </template>
+        <template v-slot:body>
+            <div>
+                Upon loading, your cloud {{ pluralizedSave }}
+                {{ conflictingSaves.length > 1 ? "appear" : "appears" }} to be out of sync with your
+                local {{ pluralizedSave }}. Which
+                {{ pluralizedSave }}
+                do you want to keep?
+            </div>
+            <br />
+            <div
+                v-for="(conflict, i) in unref(conflictingSaves)"
+                :key="conflict.id"
+                class="conflict-container"
+            >
+                <div @click="selectCloud(i)" :class="{ selected: selectedSaves[i] === 'cloud' }">
+                    <h2>
+                        Cloud
+                        <span
+                            v-if="(conflict.cloud.time ?? 0) > (conflict.local.time ?? 0)"
+                            class="note"
+                            >(more recent)</span
+                        >
+                        <span
+                            v-if="
+                                (conflict.cloud.timePlayed ?? 0) > (conflict.local.timePlayed ?? 0)
+                            "
+                            class="note"
+                            >(more playtime)</span
+                        >
+                    </h2>
+                    <Save :save="conflict.cloud" :readonly="true" />
+                </div>
+                <div @click="selectLocal(i)" :class="{ selected: selectedSaves[i] === 'local' }">
+                    <h2>
+                        Local
+                        <span
+                            v-if="(conflict.cloud.time ?? 0) <= (conflict.local.time ?? 0)"
+                            class="note"
+                            >(more recent)</span
+                        >
+                        <span
+                            v-if="
+                                (conflict.cloud.timePlayed ?? 0) <= (conflict.local.timePlayed ?? 0)
+                            "
+                            class="note"
+                            >(more playtime)</span
+                        >
+                    </h2>
+                    <Save :save="conflict.local" :readonly="true" />
+                </div>
+                <div
+                    @click="selectBoth(i)"
+                    :class="{ selected: selectedSaves[i] === 'both' }"
+                    style="flex-basis: 30%"
+                >
+                    <h2>Both</h2>
+                    <div class="save">Keep Both</div>
+                </div>
+            </div>
+        </template>
+        <template v-slot:footer>
+            <div class="cloud-saves-footer">
+                <button @click="close" class="button">Confirm</button>
+            </div>
+        </template>
+    </Modal>
+</template>
+
+<script setup lang="ts">
+import Modal from "components/Modal.vue";
+import { stringifySave } from "game/player";
+import settings from "game/settings";
+import LZString from "lz-string";
+import { conflictingSaves, galaxy } from "util/galaxy";
+import { getUniqueID, save, setupInitialStore } from "util/save";
+import { ComponentPublicInstance, computed, ref, unref, watch } from "vue";
+import Save from "./Save.vue";
+
+const isOpen = ref(false);
+// True means replacing local save with cloud save
+const selectedSaves = ref<("cloud" | "local" | "both")[]>([]);
+
+const pluralizedSave = computed(() => (conflictingSaves.value.length > 1 ? "saves" : "save"));
+
+const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null);
+
+watch(
+    () => conflictingSaves.value.length > 0,
+    shouldOpen => {
+        if (shouldOpen) {
+            selectedSaves.value = conflictingSaves.value.map(({ local, cloud }) => {
+                return (local.time ?? 0) < (cloud.time ?? 0) ? "cloud" : "local";
+            });
+            isOpen.value = true;
+        }
+    },
+    { immediate: true }
+);
+
+watch(
+    () => modal.value?.isOpen,
+    open => {
+        if (open === false) {
+            conflictingSaves.value = [];
+        }
+    }
+);
+
+function selectLocal(index: number) {
+    selectedSaves.value[index] = "local";
+}
+
+function selectCloud(index: number) {
+    selectedSaves.value[index] = "cloud";
+}
+
+function selectBoth(index: number) {
+    selectedSaves.value[index] = "both";
+}
+
+function close() {
+    for (let i = 0; i < selectedSaves.value.length; i++) {
+        const { slot, local, cloud } = conflictingSaves.value[i];
+        switch (selectedSaves.value[i]) {
+            case "local":
+                // Replace cloud save with local
+                galaxy.value
+                    ?.save(
+                        slot,
+                        LZString.compressToUTF16(stringifySave(setupInitialStore(local))),
+                        cloud.name
+                    )
+                    .catch(console.error);
+                break;
+            case "cloud":
+                // Replace local save with cloud
+                save(setupInitialStore(cloud));
+                break;
+            case "both":
+                // Get a new save ID for the cloud save, and sync the local one to the cloud
+                const id = getUniqueID();
+                save({ ...setupInitialStore(cloud), id });
+                settings.saves.push(id);
+                galaxy.value
+                    ?.save(
+                        slot,
+                        LZString.compressToUTF16(stringifySave(setupInitialStore(local))),
+                        cloud.name
+                    )
+                    .catch(console.error);
+                break;
+        }
+    }
+    isOpen.value = false;
+}
+</script>
+
+<style scoped>
+.cloud-saves-modal-header {
+    padding: 10px 0;
+    margin-left: 10px;
+}
+
+.cloud-saves-footer {
+    display: flex;
+    justify-content: flex-end;
+}
+
+.cloud-saves-footer button {
+    margin: 0 10px;
+}
+
+.conflict-container {
+    display: flex;
+}
+
+.conflict-container > * {
+    flex-basis: 50%;
+    display: flex;
+    flex-flow: column;
+    margin: 0;
+}
+
+.conflict-container + .conflict-container {
+    margin-top: 1em;
+}
+
+.conflict-container h2 {
+    display: flex;
+    flex-flow: column wrap;
+    height: 1.5em;
+    margin: 0;
+}
+
+.note {
+    font-size: x-small;
+    opacity: 0.7;
+    margin-right: 1em;
+}
+
+.save {
+    border: solid 4px var(--outline);
+    padding: 4px;
+    background: var(--raised-background);
+    margin: var(--feature-margin);
+    display: flex;
+    align-items: center;
+    min-height: 30px;
+    height: 100%;
+}
+</style>
+
+<style>
+.conflict-container .save {
+    cursor: pointer;
+}
+
+.conflict-container .selected .save {
+    border-color: var(--bought);
+}
+</style>
diff --git a/src/components/Save.vue b/src/components/saves/Save.vue
similarity index 75%
rename from src/components/Save.vue
rename to src/components/saves/Save.vue
index 77d2988..8c1809b 100644
--- a/src/components/Save.vue
+++ b/src/components/saves/Save.vue
@@ -1,7 +1,7 @@
 <template>
-    <div class="save" :class="{ active: isActive }">
-        <div class="handle material-icons">drag_handle</div>
-        <div class="actions" v-if="!isEditing">
+    <div class="save" :class="{ active: isActive, readonly }">
+        <div class="handle material-icons" v-if="readonly !== true">drag_handle</div>
+        <div class="actions" v-if="!isEditing && readonly !== true">
             <FeedbackButton
                 @click="emit('export')"
                 class="button"
@@ -40,7 +40,7 @@
                 </Tooltip>
             </DangerButton>
         </div>
-        <div class="actions" v-else>
+        <div class="actions" v-else-if="readonly !== true">
             <button @click="changeName" class="button">
                 <Tooltip display="Save" :direction="Direction.Left" class="info">
                     <span class="material-icons">check</span>
@@ -53,12 +53,17 @@
             </button>
         </div>
         <div class="details" v-if="save.error == undefined && !isEditing">
-            <button class="button open" @click="emit('open')">
+            <Tooltip display="Synced!" :direction="Direction.Right" v-if="synced"
+                ><span class="material-icons synced">cloud</span></Tooltip
+            >
+            <button class="button open" @click="emit('open')" :disabled="readonly">
                 <h3>{{ save.name }}</h3>
             </button>
             <span class="save-version">v{{ save.modVersion }}</span
             ><br />
-            <div v-if="currentTime">Last played {{ dateFormat.format(currentTime) }}</div>
+            <div v-if="currentTime" class="time">
+                Last played {{ dateFormat.format(currentTime) }}
+            </div>
         </div>
         <div class="details" v-else-if="save.error == undefined && isEditing">
             <Text v-model="newName" class="editname" @submit="changeName" />
@@ -73,16 +78,18 @@
 import Tooltip from "features/tooltips/Tooltip.vue";
 import player from "game/player";
 import { Direction } from "util/common";
-import { computed, ref, toRefs, watch } from "vue";
-import DangerButton from "./fields/DangerButton.vue";
-import FeedbackButton from "./fields/FeedbackButton.vue";
-import Text from "./fields/Text.vue";
+import { computed, ref, toRefs, unref, watch } from "vue";
+import DangerButton from "../fields/DangerButton.vue";
+import FeedbackButton from "../fields/FeedbackButton.vue";
+import Text from "../fields/Text.vue";
 import type { LoadablePlayerData } from "./SavesManager.vue";
+import { galaxy, syncedSaves } from "util/galaxy";
 
 const _props = defineProps<{
     save: LoadablePlayerData;
+    readonly?: boolean;
 }>();
-const { save } = toRefs(_props);
+const { save, readonly } = toRefs(_props);
 const emit = defineEmits<{
     (e: "export"): void;
     (e: "open"): void;
@@ -106,10 +113,18 @@ const newName = ref("");
 
 watch(isEditing, () => (newName.value = save.value.name ?? ""));
 
-const isActive = computed(() => save.value != null && save.value.id === player.id);
+const isActive = computed(
+    () => save.value != null && save.value.id === player.id && !unref(readonly)
+);
 const currentTime = computed(() =>
     isActive.value ? player.time : (save.value != null && save.value.time) ?? 0
 );
+const synced = computed(
+    () =>
+        !unref(readonly) &&
+        galaxy.value?.loggedIn === true &&
+        syncedSaves.value.includes(save.value.id)
+);
 
 function changeName() {
     emit("editName", newName.value);
@@ -139,6 +154,13 @@ function changeName() {
     padding-left: 0;
 }
 
+.open:disabled {
+    cursor: inherit;
+    color: var(--foreground);
+    opacity: 1;
+    pointer-events: none;
+}
+
 .handle {
     flex-grow: 0;
     margin-right: 8px;
@@ -152,6 +174,10 @@ function changeName() {
     margin-right: 80px;
 }
 
+.save.readonly .details {
+    margin-right: 0;
+}
+
 .error {
     font-size: 0.8em;
     color: var(--danger);
@@ -176,6 +202,17 @@ function changeName() {
 .editname {
     margin: 0;
 }
+
+.time {
+    font-size: small;
+}
+
+.synced {
+    font-size: 100%;
+    margin-right: 0.5em;
+    vertical-align: middle;
+    cursor: default;
+}
 </style>
 
 <style>
@@ -201,4 +238,8 @@ function changeName() {
 .save .field {
     margin: 0;
 }
+
+.details > .tooltip-container {
+    display: inline;
+}
 </style>
diff --git a/src/components/SavesManager.vue b/src/components/saves/SavesManager.vue
similarity index 70%
rename from src/components/SavesManager.vue
rename to src/components/saves/SavesManager.vue
index b1bf7e0..4edb2e0 100644
--- a/src/components/SavesManager.vue
+++ b/src/components/saves/SavesManager.vue
@@ -4,6 +4,9 @@
             <h2>Saves Manager</h2>
         </template>
         <template #body="{ shown }">
+            <div v-if="showNotSyncedWarning" style="color: var(--danger)">
+                Not all saves are synced! You may need to delete stale saves.
+            </div>
             <Draggable
                 :list="settings.saves"
                 handle=".handle"
@@ -63,13 +66,23 @@ import type { Player } from "game/player";
 import player, { stringifySave } from "game/player";
 import settings from "game/settings";
 import LZString from "lz-string";
-import { getUniqueID, loadSave, newSave, save } from "util/save";
+import {
+    clearCachedSave,
+    clearCachedSaves,
+    decodeSave,
+    getCachedSave,
+    getUniqueID,
+    loadSave,
+    newSave,
+    save
+} from "util/save";
 import type { ComponentPublicInstance } from "vue";
-import { computed, nextTick, ref, shallowReactive, watch } from "vue";
+import { computed, nextTick, ref, watch } from "vue";
 import Draggable from "vuedraggable";
-import Select from "./fields/Select.vue";
-import Text from "./fields/Text.vue";
+import Select from "../fields/Select.vue";
+import Text from "../fields/Text.vue";
 import Save from "./Save.vue";
+import { galaxy, syncedSaves } from "util/galaxy";
 
 export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown };
 
@@ -90,16 +103,8 @@ watch(saveToImport, importedSave => {
     if (importedSave) {
         nextTick(() => {
             try {
-                if (importedSave[0] === "{") {
-                    // plaintext. No processing needed
-                } else if (importedSave[0] === "e") {
-                    // Assumed to be base64, which starts with e
-                    importedSave = decodeURIComponent(escape(atob(importedSave)));
-                } else if (importedSave[0] === "ᯡ") {
-                    // Assumed to be lz, which starts with ᯡ
-                    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                    importedSave = LZString.decompressFromUTF16(importedSave)!;
-                } else {
+                importedSave = decodeSave(importedSave) ?? "";
+                if (importedSave === "") {
                     console.warn("Unable to determine preset encoding", importedSave);
                     importingFailed.value = true;
                     return;
@@ -139,48 +144,10 @@ let bank = ref(
     }, [])
 );
 
-const cachedSaves = shallowReactive<Record<string, LoadablePlayerData | undefined>>({});
-function getCachedSave(id: string) {
-    if (cachedSaves[id] == null) {
-        let save = localStorage.getItem(id);
-        if (save == null) {
-            cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id };
-        } else if (save === "dW5kZWZpbmVk") {
-            cachedSaves[id] = { error: `Save is undefined`, id };
-        } else {
-            try {
-                if (save[0] === "{") {
-                    // plaintext. No processing needed
-                } else if (save[0] === "e") {
-                    // Assumed to be base64, which starts with e
-                    save = decodeURIComponent(escape(atob(save)));
-                } else if (save[0] === "ᯡ") {
-                    // Assumed to be lz, which starts with ᯡ
-                    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                    save = LZString.decompressFromUTF16(save)!;
-                } else {
-                    console.warn("Unable to determine preset encoding", save);
-                    importingFailed.value = true;
-                    cachedSaves[id] = { error: "Unable to determine preset encoding", id };
-                    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                    return cachedSaves[id]!;
-                }
-                cachedSaves[id] = { ...JSON.parse(save), id };
-            } catch (error) {
-                cachedSaves[id] = { error, id };
-                console.warn(
-                    `SavesManager: Failed to load info about save with id ${id}:\n${error}\n${save}`
-                );
-            }
-        }
-    }
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    return cachedSaves[id]!;
-}
 // Wipe cache whenever the modal is opened
 watch(isOpen, isOpen => {
     if (isOpen) {
-        Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]);
+        clearCachedSaves();
     }
 });
 
@@ -191,6 +158,10 @@ const saves = computed(() =>
     }, {})
 );
 
+const showNotSyncedWarning = computed(
+    () => galaxy.value?.loggedIn === true && settings.saves.length < syncedSaves.value.length
+);
+
 function exportSave(id: string) {
     let saveToExport;
     if (player.id === id) {
@@ -233,20 +204,37 @@ function duplicateSave(id: string) {
 }
 
 function deleteSave(id: string) {
+    if (galaxy.value?.loggedIn === true) {
+        galaxy.value.getSaveList().then(list => {
+            const slot = Object.keys(list).find(slot => {
+                const content = list[slot as unknown as number].content;
+                try {
+                    if (JSON.parse(content).id === id) {
+                        return true;
+                    }
+                } catch (e) {
+                    return false;
+                }
+            });
+            if (slot != null) {
+                galaxy.value?.save(parseInt(slot), "", "").catch(console.error);
+            }
+        });
+    }
     settings.saves = settings.saves.filter((save: string) => save !== id);
     localStorage.removeItem(id);
-    cachedSaves[id] = undefined;
+    clearCachedSave(id);
 }
 
 function openSave(id: string) {
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     saves.value[player.id]!.time = player.time;
     save();
-    cachedSaves[player.id] = undefined;
+    clearCachedSave(player.id);
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     loadSave(saves.value[id]!);
     // Delete cached version in case of opening it again
-    cachedSaves[id] = undefined;
+    clearCachedSave(id);
 }
 
 function newFromPreset(preset: string) {
@@ -256,16 +244,8 @@ function newFromPreset(preset: string) {
         selectedPreset.value = null;
     });
 
-    if (preset[0] === "{") {
-        // plaintext. No processing needed
-    } else if (preset[0] === "e") {
-        // Assumed to be base64, which starts with e
-        preset = decodeURIComponent(escape(atob(preset)));
-    } else if (preset[0] === "ᯡ") {
-        // Assumed to be lz, which starts with ᯡ
-        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        preset = LZString.decompressFromUTF16(preset)!;
-    } else {
+    preset = decodeSave(preset) ?? "";
+    if (preset === "") {
         console.warn("Unable to determine preset encoding", preset);
         return;
     }
@@ -287,7 +267,7 @@ function editSave(id: string, newName: string) {
             save();
         } else {
             save(currSave as Player);
-            cachedSaves[id] = undefined;
+            clearCachedSave(id);
         }
     }
 }
diff --git a/src/game/settings.ts b/src/game/settings.ts
index 0748d68..6f3a435 100644
--- a/src/game/settings.ts
+++ b/src/game/settings.ts
@@ -3,7 +3,7 @@ import { Themes } from "data/themes";
 import type { CoercableComponent } from "features/feature";
 import { globalBus } from "game/events";
 import LZString from "lz-string";
-import { hardReset } from "util/save";
+import { decodeSave, hardReset } from "util/save";
 import { reactive, watch } from "vue";
 
 /** The player's settings object. */
@@ -78,16 +78,8 @@ export function loadSettings(): void {
     try {
         let item: string | null = localStorage.getItem(projInfo.id);
         if (item != null && item !== "") {
-            if (item[0] === "{") {
-                // plaintext. No processing needed
-            } else if (item[0] === "e") {
-                // Assumed to be base64, which starts with e
-                item = decodeURIComponent(escape(atob(item)));
-            } else if (item[0] === "ᯡ") {
-                // Assumed to be lz, which starts with ᯡ
-                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                item = LZString.decompressFromUTF16(item)!;
-            } else {
+            item = decodeSave(item);
+            if (item == null) {
                 console.warn("Unable to determine settings encoding", item);
                 return;
             }
diff --git a/src/main.css b/src/main.css
index 60188bd..f84ba5f 100644
--- a/src/main.css
+++ b/src/main.css
@@ -66,3 +66,7 @@ ul {
 .Vue-Toastification__toast {
 	margin: unset;
 }
+
+:disabled {
+	pointer-events: none;
+}
diff --git a/src/main.ts b/src/main.ts
index e416fa5..3b5de9f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -8,6 +8,7 @@ import { useRegisterSW } from "virtual:pwa-register/vue";
 import type { App as VueApp } from "vue";
 import { createApp, nextTick } from "vue";
 import { useToast } from "vue-toastification";
+import "util/galaxy";
 
 declare global {
     /**
diff --git a/src/util/galaxy.ts b/src/util/galaxy.ts
new file mode 100644
index 0000000..384ed3d
--- /dev/null
+++ b/src/util/galaxy.ts
@@ -0,0 +1,185 @@
+import { LoadablePlayerData } from "components/saves/SavesManager.vue";
+import player, { Player, stringifySave } from "game/player";
+import settings from "game/settings";
+import { GalaxyApi, initGalaxy } from "unofficial-galaxy-sdk";
+import LZString from "lz-string";
+import { ref } from "vue";
+import { decodeSave, loadSave, save, setupInitialStore } from "./save";
+
+export const galaxy = ref<GalaxyApi>();
+export const conflictingSaves = ref<
+    { id: string; local: LoadablePlayerData; cloud: LoadablePlayerData; slot: number }[]
+>([]);
+export const syncedSaves = ref<string[]>([]);
+
+export function sync() {
+    if (galaxy.value?.loggedIn !== true) {
+        return;
+    }
+    if (conflictingSaves.value.length > 0) {
+        // Pause syncing while resolving conflicted saves
+        return;
+    }
+    galaxy.value
+        .getSaveList()
+        .then(syncSaves)
+        .then(list => {
+            syncedSaves.value = list.map(s => s.content.id);
+        })
+        .catch(console.error);
+}
+
+// Setup Galaxy API
+initGalaxy({
+    supportsSaving: true,
+    supportsSaveManager: true,
+    onLoggedInChanged
+})
+    .then(g => {
+        galaxy.value = g;
+        onLoggedInChanged(g);
+    })
+    .catch(console.error);
+
+function onLoggedInChanged(g: GalaxyApi) {
+    if (g.loggedIn !== true) {
+        return;
+    }
+    if (conflictingSaves.value.length > 0) {
+        // Pause syncing while resolving conflicted saves
+        return;
+    }
+
+    g.getSaveList()
+        .then(list => {
+            const saves = syncSaves(list);
+            syncedSaves.value = saves.map(s => s.content.id);
+
+            // If our current save has under 2 minutes of playtime, load the cloud save with the most recent time.
+            if (player.timePlayed < 120 * 1000 && saves.length > 0) {
+                const longestSave = saves.reduce((acc, curr) =>
+                    acc.content.time < curr.content.time ? curr : acc
+                );
+                loadSave(longestSave.content);
+            }
+        })
+        .catch(console.error);
+
+    setInterval(sync, 60 * 1000);
+}
+
+function syncSaves(
+    list: Record<
+        number,
+        {
+            label: string;
+            content: string;
+        }
+    >
+) {
+    const savesToUpload = new Set(settings.saves.slice());
+    const availableSlots = new Set(new Array(11).fill(0).map((_, i) => i));
+    const saves = (
+        Object.keys(list)
+            .map(slot => {
+                const { label, content } = list[slot as unknown as number];
+                try {
+                    return {
+                        slot: parseInt(slot),
+                        label,
+                        content: JSON.parse(decodeSave(content) ?? "")
+                    };
+                } catch (e) {
+                    return null;
+                }
+            })
+            .filter(
+                n =>
+                    n != null &&
+                    typeof n.content.id === "string" &&
+                    typeof n.content.time === "number" &&
+                    typeof n.content.timePlayed === "number"
+            ) as {
+            slot: number;
+            label?: string;
+            content: Partial<Player> & { id: string; time: number; timePlayed: number };
+        }[]
+    ).filter(cloudSave => {
+        if (cloudSave.label != null) {
+            cloudSave.content.name = cloudSave.label;
+        }
+        availableSlots.delete(cloudSave.slot);
+        const localSaveId = settings.saves.find(id => id === cloudSave.content.id);
+        if (localSaveId == undefined) {
+            settings.saves.push(cloudSave.content.id);
+            save(setupInitialStore(cloudSave.content));
+        } else {
+            savesToUpload.delete(localSaveId);
+            try {
+                const localSave = JSON.parse(
+                    decodeSave(localStorage.getItem(localSaveId) ?? "") ?? ""
+                ) as Partial<Player> | null;
+                if (localSave == null) {
+                    return false;
+                }
+                localSave.id = localSaveId;
+                localSave.time = localSave.time ?? 0;
+                localSave.timePlayed = localSave.timePlayed ?? 0;
+
+                const timePlayedDiff = Math.abs(
+                    localSave.timePlayed - cloudSave.content.timePlayed
+                );
+                const timeDiff = Math.abs(localSave.time - cloudSave.content.time);
+                // If their last played time and total time played are both within 2 minutes, just use the newer save (very unlikely to be coincidence)
+                // Otherwise, ask the player
+                if (timePlayedDiff < 120 * 1000 && timeDiff < 120 * 1000) {
+                    if (localSave.time < cloudSave.content.time) {
+                        save(setupInitialStore(cloudSave.content));
+                        if (settings.active === localSaveId) {
+                            loadSave(cloudSave.content);
+                        }
+                    } else {
+                        galaxy.value
+                            ?.save(
+                                cloudSave.slot,
+                                LZString.compressToUTF16(
+                                    stringifySave(setupInitialStore(localSave))
+                                ),
+                                localSave.name ?? cloudSave.label
+                            )
+                            .catch(console.error);
+                        // Update cloud save content for the return value
+                        cloudSave.content = localSave as Player;
+                    }
+                } else {
+                    conflictingSaves.value.push({
+                        id: localSaveId,
+                        cloud: cloudSave.content,
+                        local: localSave as LoadablePlayerData,
+                        slot: cloudSave.slot
+                    });
+                }
+            } catch (e) {
+                return false;
+            }
+        }
+        return true;
+    });
+
+    savesToUpload.forEach(id => {
+        try {
+            if (availableSlots.size > 0) {
+                const localSave = localStorage.getItem(id) ?? "";
+                const parsedLocalSave = JSON.parse(decodeSave(localSave) ?? "");
+                const slot = availableSlots.values().next().value;
+                galaxy.value
+                    ?.save(slot, localSave, parsedLocalSave.name)
+                    .then(() => syncedSaves.value.push(parsedLocalSave.id))
+                    .catch(console.error);
+                availableSlots.delete(slot);
+            }
+        } catch (e) {}
+    });
+
+    return saves;
+}
diff --git a/src/util/save.ts b/src/util/save.ts
index 1578732..86c7691 100644
--- a/src/util/save.ts
+++ b/src/util/save.ts
@@ -1,10 +1,11 @@
+import { LoadablePlayerData } from "components/saves/SavesManager.vue";
 import projInfo from "data/projInfo.json";
 import { globalBus } from "game/events";
 import type { Player } from "game/player";
 import player, { stringifySave } from "game/player";
 import settings, { loadSettings } from "game/settings";
 import LZString from "lz-string";
-import { ref } from "vue";
+import { ref, shallowReactive } from "vue";
 
 export function setupInitialStore(player: Partial<Player> = {}): Player {
     return Object.assign(
@@ -42,17 +43,9 @@ export async function load(): Promise<void> {
             await loadSave(newSave());
             return;
         }
-        if (save[0] === "{") {
-            // plaintext. No processing needed
-        } else if (save[0] === "e") {
-            // Assumed to be base64, which starts with e
-            save = decodeURIComponent(escape(atob(save)));
-        } else if (save[0] === "ᯡ") {
-            // Assumed to be lz, which starts with ᯡ
-            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            save = LZString.decompressFromUTF16(save)!;
-        } else {
-            throw `Unable to determine save encoding`;
+        save = decodeSave(save);
+        if (save == null) {
+            throw "Unable to determine save encoding";
         }
         const player = JSON.parse(save);
         if (player.modID !== projInfo.id) {
@@ -67,6 +60,23 @@ export async function load(): Promise<void> {
     }
 }
 
+export function decodeSave(save: string) {
+    if (save[0] === "{") {
+        // plaintext. No processing needed
+    } else if (save[0] === "e") {
+        // Assumed to be base64, which starts with e
+        save = decodeURIComponent(escape(atob(save)));
+    } else if (save[0] === "ᯡ") {
+        // Assumed to be lz, which starts with ᯡ
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        save = LZString.decompressFromUTF16(save)!;
+    } else {
+        console.warn("Unable to determine preset encoding", save);
+        return null;
+    }
+    return save;
+}
+
 export function newSave(): Player {
     const id = getUniqueID();
     const player = setupInitialStore({ id });
@@ -127,6 +137,40 @@ export async function loadSave(playerObj: Partial<Player>): Promise<void> {
     globalBus.emit("onLoad");
 }
 
+const cachedSaves = shallowReactive<Record<string, LoadablePlayerData | undefined>>({});
+export function getCachedSave(id: string) {
+    if (cachedSaves[id] == null) {
+        let save = localStorage.getItem(id);
+        if (save == null) {
+            cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id };
+        } else if (save === "dW5kZWZpbmVk") {
+            cachedSaves[id] = { error: `Save is undefined`, id };
+        } else {
+            try {
+                save = decodeSave(save);
+                if (save == null) {
+                    console.warn("Unable to determine preset encoding", save);
+                    cachedSaves[id] = { error: "Unable to determine preset encoding", id };
+                    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+                    return cachedSaves[id]!;
+                }
+                cachedSaves[id] = { ...JSON.parse(save), id };
+            } catch (error) {
+                cachedSaves[id] = { error, id };
+                console.warn(`Failed to load info about save with id ${id}:\n${error}\n${save}`);
+            }
+        }
+    }
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    return cachedSaves[id]!;
+}
+export function clearCachedSaves() {
+    Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]);
+}
+export function clearCachedSave(id: string) {
+    cachedSaves[id] = undefined;
+}
+
 setInterval(() => {
     if (player.autosave) {
         save();