diff --git a/.github/actions/setup-haxe/action.yml b/.github/actions/setup-haxe/action.yml
new file mode 100644
index 000000000..54db9bf79
--- /dev/null
+++ b/.github/actions/setup-haxe/action.yml
@@ -0,0 +1,135 @@
+name: setup-haxeshit
+description: "sets up haxe shit, using HMM!"
+
+inputs:
+  haxe:
+    description: 'Version of haxe to install'
+    required: true
+    default: '4.3.4'
+  hxcpp-cache:
+    description: 'Whether to use a shared hxcpp compile cache'
+    required: true
+    default: 'true'
+  hxcpp-cache-path:
+    description: 'Path to create hxcpp cache in'
+    required: true
+    default: ${{ runner.temp }}/hxcpp_cache
+  targets:
+    description: 'Targets we plan to compile to. Installs native dependencies needed.'
+    required: true
+
+runs:
+  using: "composite"
+  steps:
+
+  - name: Setup timers
+    shell: bash
+    run: |
+      echo "TIMER_HAXE=$(date +%s)" >> "$GITHUB_ENV"
+
+  - name: Install Haxe
+    uses: funkincrew/ci-haxe@v3.1.0
+    with:
+      haxe-version: ${{ inputs.haxe }}
+
+  - name: Install native dependencies
+    if: ${{ runner.os == 'Linux' }}
+    shell: bash
+    run: |
+      ls -lah /usr/lib/x86_64-linux-gnu/
+      apt-get update
+      apt-get install -y \
+        g++ \
+        libx11-dev libxi-dev libxext-dev libxinerama-dev libxrandr-dev \
+        libgl-dev libgl1-mesa-dev \
+        libasound2-dev
+      ln -s /usr/lib/x86_64-linux-gnu/libffi.so.8 /usr/lib/x86_64-linux-gnu/libffi.so.6 || true
+  - name: Install linux-specific dependencies
+    if: ${{ runner.os == 'Linux' && contains(inputs.targets, 'linux') }}
+    shell: bash
+    run: |
+      apt-get install -y libvlc-dev libvlccore-dev
+
+  - name: Config haxelib
+    shell: bash
+    run: |
+      echo "TIMER_HAXELIB=$(date +%s)" >> "$GITHUB_ENV"
+      haxelib --debug --never install haxelib 4.1.0 --global
+      haxelib --debug --never deleterepo || true
+      haxelib --debug --never newrepo
+      echo "HAXEPATH=$(haxelib config)" >> "$GITHUB_ENV"
+      haxelib --debug --never git haxelib https://github.com/HaxeFoundation/haxelib.git master
+      haxelib --debug --global install hmm
+      echo "TIMER_DEPS=$(date +%s)" >> "$GITHUB_ENV"
+
+  - name: Restore cached dependencies
+    id: cache-hmm
+    uses: actions/cache@v4
+    with:
+      path: .haxelib
+      key: haxe-hmm-${{ runner.os }}-${{ hashFiles('**/hmm.json') }}
+
+  - if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }}
+    name: Install dependencies
+    shell: bash
+    run: |
+      haxelib --debug --global run hmm install
+      echo "TIMER_DONE=$(date +%s)" >> "$GITHUB_ENV"
+
+  # by default use a shared hxcpp cache
+  - if: ${{ inputs.hxcpp-cache == 'true' }}
+    name: Restore hxcpp cache
+    uses: actions/cache@v4
+    with:
+      path: ${{ inputs.hxcpp-cache-path }}
+      key: haxe-hxcpp-${{ runner.os }}-${{ github.ref_name }}-${{ github.sha }}
+      restore-keys: haxe-hxcpp-${{ runner.os }}-${{ github.ref_name }}
+  # export env for it to reuse in builds
+  - if: ${{ inputs.hxcpp-cache == 'true' }}
+    name: Persist env for hxcpp cache
+    shell: bash
+    run: |
+      echo "HXCPP_COMPILE_CACHE=${{ inputs.hxcpp-cache-path }}" >> "$GITHUB_ENV"
+      echo 'HXCPP_CACHE_MB="4096"' >> "$GITHUB_ENV"
+
+  # if it's explicitly disabled, still cache export/ since that then contains the builds
+  - if: ${{ inputs.hxcpp-cache != 'true' }}
+    name: Restore export cache
+    uses: actions/cache@v4
+    with:
+      path: ${{ inputs.hxcpp-cache-path }}
+      key: haxe-export-${{ runner.os }}-${{ github.ref_name }}-${{ github.sha }}
+      restore-keys: haxe-export-${{ runner.os }}-${{ github.ref_name }}
+
+  - name: Print debug info
+    shell: bash
+    run: |
+      cat << EOF
+      runner:
+        kernel: $(uname -a)
+      haxe:
+        version: $(haxe -version)
+        which: $(which haxe)
+        haxepath: $HAXEPATH
+        took: $((TIMER_HAXELIB - TIMER_HAXE))s
+      haxelib:
+        version: $(haxelib version)
+        which: $(which haxelib)
+        local:
+          config: $(haxelib config)
+          path: $(haxelib path haxelib || true)
+        global
+          config: $(haxelib config --global)
+          path: $(haxelib path haxelib --global || true)
+        system
+          version: $(haxelib --system version)
+          local:
+            config: $(haxelib --system config)
+          global:
+            config: $(haxelib --system config --global)
+        took: $((TIMER_DEPS - TIMER_HAXELIB))s
+      deps:
+        took: $((TIMER_DONE - TIMER_DEPS))s
+      hxcpp_cache: |
+      $(haxelib run hxcpp cache list || true)
+      EOF
diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml
deleted file mode 100644
index 236d29944..000000000
--- a/.github/actions/setup-haxeshit/action.yml
+++ /dev/null
@@ -1,55 +0,0 @@
-name: setup-haxeshit
-description: "sets up haxe shit, using HMM!"
-runs:
-  using: "composite"
-  steps:
-    - name: Install Haxe lol
-      uses: funkincrew/ci-haxe@v3.1.0
-      with:
-        haxe-version: 4.3.3
-    - name: Config haxelib
-      run: |
-        haxelib --never install haxelib 4.1.0 --global
-        haxelib --never deleterepo || true
-        haxelib --never newrepo
-        echo "HAXEPATH=$(haxelib config)" >> "$GITHUB_ENV"
-        haxelib --never git haxelib https://github.com/HaxeFoundation/haxelib.git master
-      shell: bash
-    - name: Gather debug info
-      run: |
-        cat << EOF >> "$GITHUB_STEP_SUMMARY"
-        ## haxe
-        - version: \`$(haxe -version)\`
-        - exe: \`$(which haxe)\`
-        ## haxelib
-        - version: \`$(haxelib version)\`
-        - exe: \`$(which haxelib)\`
-        - path: \`$HAXEPATH\`
-        ### local
-        - config: \`$(haxelib config)\`
-        - path: \`$(haxelib path haxelib || true)\`
-        ### global
-        - config: \`$(haxelib config --global)\`
-        - path: \`$(haxelib path haxelib --global || true)\`
-        ### system
-        - version: \`$(haxelib --system version)\`
-        - local: \`$(haxelib --system config)\`
-        - global: \`$(haxelib --system config --global)\`
-        EOF
-      shell: bash
-    - name: Install hmm
-      # hmm only supports global installs
-      run: |
-        haxelib --global install hmm
-      shell: bash
-    - name: Restore cached dependencies
-      id: cache-hmm
-      uses: actions/cache@v4
-      with:
-        path: .haxelib
-        key: ${{ runner.os }}-hmm-${{ hashFiles('**/hmm.json') }}
-    - if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }}
-      name: hmm install
-      run: |
-        haxelib --global run hmm install
-      shell: bash
diff --git a/.github/actions/upload-itch/action.yml b/.github/actions/upload-itch/action.yml
index 2f7d3027d..fb049efc9 100644
--- a/.github/actions/upload-itch/action.yml
+++ b/.github/actions/upload-itch/action.yml
@@ -1,44 +1,124 @@
 name: upload-itch
 description: "installs Butler, and uploads to itch.io!"
+
 inputs:
   butler-key:
     description: "Butler API secret key"
     required: true
+  itch-repo:
+    description: "Where to upload the game to"
+    required: true
+    default: "ninja-muffin24/funkin-secret"
   build-dir:
     description: "Directory of the game build"
-    required: true
+    required: false
   target:
-    description: "Target (html5, win, linux, mac)"
+    description: "Target (html5, windows, linux, macos)"
     required: true
+
 runs:
   using: "composite"
   steps:
-    - name: Install butler Windows
-      if: runner.os == 'Windows'
-      run: |
-        curl -L -o butler.zip https://broth.itch.ovh/butler/windows-amd64/LATEST/archive/default
-        7z x butler.zip
-        ./butler -v
-      shell: bash
-    - name: Install butler Mac
-      if: runner.os == 'macOS'
-      run: |
-        curl -L -o butler.zip https://broth.itch.ovh/butler/darwin-amd64/LATEST/archive/default
-        unzip butler.zip
-        ./butler -V
-      shell: bash
-    - name: Install butler Linux
-      if: runner.os == 'Linux'
-      run: |
-        curl -L -o butler.zip https://broth.itch.ovh/butler/linux-amd64/LATEST/archive/default
-        unzip butler.zip
-        chmod +x butler
-        ./butler -V
-      shell: bash
-    - name: Upload game to itch.io
-      env:
-        BUTLER_API_KEY: ${{inputs.butler-key}}
-      run: |
-        ./butler login
-        ./butler push ${{inputs.build-dir}} ninja-muffin24/funkin-secret:${{inputs.target}}-${GITHUB_REF_NAME}
-      shell: bash
+
+  # RUNNER_OS = Windows | macOS | Linux
+  # TARGET_BUILD = windows | macos | linux
+  # TARGET_ITCH = win | macos | linux
+  # TARGET_BUTLER_DOWNLOAD = windows-amd64 | darwin-amd64 | linux-amd64
+  - name: Setup variables
+    shell: bash
+    run: |
+      TARGET_OS=${{ inputs.target }}
+      RUNNER=${RUNNER_OS@L}
+      TARGET=${TARGET_OS@L}
+      case "$TARGET" in
+        "windows")
+          TARGET_ITCH=win
+          ;;
+        *)
+          TARGET_ITCH=$TARGET
+          ;;
+      esac
+      case "$RUNNER" in
+        "macos")
+          OS_NODE=darwin
+          ;;
+        *)
+          OS_NODE=$RUNNER
+          ;;
+      esac
+      BUTLER_PATH=$RUNNER_TEMP/butler
+
+      echo BUILD_DIR="export/release/$TARGET/bin" >> "$GITHUB_ENV"
+      echo ITCH_TAG=${{ inputs.itch-repo }}:$TARGET_ITCH-$GITHUB_REF_NAME >> "$GITHUB_ENV"
+      echo OS_AND_ARCH=$OS_NODE-amd64 >> "$GITHUB_ENV"
+      echo BUTLER_API_KEY=${{ inputs.butler-key }} >> "$GITHUB_ENV"
+      echo BUTLER_INSTALL_PATH=$BUTLER_PATH >> "$GITHUB_ENV"
+      echo TIMER_BUTLER=$(date +%s) >> "$GITHUB_ENV"
+      echo TARGET_ITCH=$TARGET_ITCH >> "$GITHUB_ENV"
+
+      echo "$BUTLER_PATH" >> "$GITHUB_PATH"
+
+  - name: Get latest butler version
+    shell: bash
+    run: |
+      LATEST=$(curl -sfL https://broth.itch.ovh/butler/$OS_AND_ARCH/LATEST)
+      echo BUTLER_LATEST=$LATEST >> "$GITHUB_ENV"
+
+      command -v butler \
+        && echo BUTLER_CURRENT=$(butler -V 2>&1 | cut -d ',' -f 1) >> "$GITHUB_ENV" \
+        || echo BUTLER_CURRENT=none >> "$GITHUB_ENV"
+
+  - name: Try to get butler from cache
+    id: cache-butler
+    uses: actions/cache@v4
+    with:
+      path: ${{ env.BUTLER_INSTALL_PATH }}
+      key: butler-${{ runner.os }}-${{ env.BUTLER_LATEST }}
+
+  - if: steps.cache-butler.outputs.cache-hit == 'true'
+    name: Make sure butler is executable
+    shell: bash
+    run: |
+      chmod +x $BUTLER_INSTALL_PATH/butler
+
+  - if: steps.cache-butler.outputs.cache-hit != 'true'
+    name: Install butler
+    shell: bash
+    run: |
+      mkdir -p $BUTLER_INSTALL_PATH
+      cd $BUTLER_INSTALL_PATH
+
+      curl -L -o butler.zip https://broth.itch.ovh/butler/$OS_AND_ARCH/LATEST/archive/default
+      unzip butler.zip
+      chmod +x butler
+
+  - name: Upload game to itch.io
+    shell: bash
+    run: |
+      echo "TIMER_UPLOAD=$(date +%s)" >> "$GITHUB_ENV"
+      butler login
+      butler push $BUILD_DIR $ITCH_TAG
+      echo "TIMER_DONE=$(date +%s)" >> "$GITHUB_ENV"
+
+  - name: Print debug info
+    shell: bash
+    run: |
+      cat << EOF
+      butler:
+        version: $(
+          if [[ "$BUTLER_CURRENT" == "$BUTLER_LATEST" ]]
+          then
+            echo $BUTLER_CURRENT
+          else
+            echo $BUTLER_CURRENT -> $BUTLER_LATEST
+          fi
+        )
+        install:
+          took: $(($TIMER_UPLOAD-$TIMER_BUTLER))s
+        upload:
+          tag: $TARGET_ITCH/$GITHUB_REF_NAME
+          took: $(($TIMER_DONE-$TIMER_UPLOAD))s
+      EOF
+      cat << EOF >> "$GITHUB_STEP_SUMMARY"
+      ### open in launcher: [$TARGET_ITCH/$GITHUB_REF_NAME](https://run.funkin.me/$TARGET_ITCH/$GITHUB_REF_NAME)
+      EOF
diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml
new file mode 100644
index 000000000..15c9e5582
--- /dev/null
+++ b/.github/workflows/build-docker-image.yml
@@ -0,0 +1,53 @@
+name: Create and publish Docker image
+
+on:
+  workflow_dispatch:
+  push:
+    paths:
+      - '**/Dockerfile'
+      - '.github/workflows/build-docker-image.yml'
+
+jobs:
+  build-and-push-image:
+    runs-on: build-set
+    permissions:
+      contents: read
+      packages: write
+
+    steps:
+      - name: Get checkout token
+        uses: actions/create-github-app-token@v1
+        id: app_token
+        with:
+          app-id: ${{ vars.APP_ID }}
+          private-key: ${{ secrets.APP_PEM }}
+          owner: ${{ github.repository_owner }}
+
+      - name: Checkout repo
+        uses: funkincrew/ci-checkout@v6
+        with:
+          submodules: false
+          token: ${{ steps.app_token.outputs.token }}
+
+      - name: Log into GitHub Container Registry
+        uses: docker/login-action@v3.1.0
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Build and push Docker image
+        uses: docker/build-push-action@v5.3.0
+        with:
+          context: ./build
+          push: true
+          tags: |
+            ghcr.io/funkincrew/build-dependencies:latest
+            ghcr.io/funkincrew/build-dependencies:${{ github.sha }}
+          labels: |
+            org.opencontainers.image.description=precooked haxe build-dependencies
+            org.opencontainers.image.revision=${{ github.sha }}
+            org.opencontainers.image.source=https://github.com/${{ github.repository }}
+            org.opencontainers.image.title=${{ github.repository_owner }}/build-dependencies
+            org.opencontainers.image.url=https://github.com/${{ github.repository }}
+            org.opencontainers.image.version=${{ github.sha }}
diff --git a/.github/workflows/build-game.yml b/.github/workflows/build-game.yml
new file mode 100644
index 000000000..3bfea20f2
--- /dev/null
+++ b/.github/workflows/build-game.yml
@@ -0,0 +1,125 @@
+name: Build and Upload nightly game builds
+
+on:
+  workflow_dispatch:
+  push:
+    paths-ignore:
+    - '**/Dockerfile'
+    - '.github/workflows/build-docker-image.yml'
+
+jobs:
+
+  build-game-on-host:
+    strategy:
+      matrix:
+        include:
+        - target: windows
+        - target: macos
+    runs-on:
+    - ${{ matrix.target }}
+    defaults:
+      run:
+        shell: bash
+
+    steps:
+    - name: Make git happy
+      if: ${{ matrix.target == 'macos' }}
+      run: |
+        git config --global --add safe.directory $GITHUB_WORKSPACE
+
+    - name: Get checkout token
+      uses: actions/create-github-app-token@v1
+      id: app_token
+      with:
+        app-id: ${{ vars.APP_ID }}
+        private-key: ${{ secrets.APP_PEM }}
+        owner: ${{ github.repository_owner }}
+
+    - name: Checkout repo
+      uses: funkincrew/ci-checkout@v6
+      with:
+        submodules: 'recursive'
+        token: ${{ steps.app_token.outputs.token }}
+
+    - name: Setup build environment
+      uses: ./.github/actions/setup-haxe
+
+    - name: Build game
+      if: ${{ matrix.target == 'windows' }}
+      run: |
+        haxelib run lime build windows -v -release -DGITHUB_BUILD
+      timeout-minutes: 120
+    - name: Build game
+      if: ${{ matrix.target != 'windows' }}
+      run: |
+        haxelib run lime build ${{ matrix.target }} -v -release --times -DGITHUB_BUILD
+      timeout-minutes: 120
+
+    - name: Upload build artifacts
+      uses: ./.github/actions/upload-itch
+      with:
+        butler-key: ${{ secrets.BUTLER_API_KEY}}
+        target: ${{ matrix.target }}
+
+  build-game-in-container:
+    runs-on: build-set
+    container: ghcr.io/funkincrew/build-dependencies:latest
+    strategy:
+      matrix:
+        include:
+        - target: linux
+        - target: html5
+    defaults:
+      run:
+        shell: bash
+
+    steps:
+    - name: Get checkout token
+      uses: actions/create-github-app-token@v1
+      id: app_token
+      with:
+        app-id: ${{ vars.APP_ID }}
+        private-key: ${{ secrets.APP_PEM }}
+        owner: ${{ github.repository_owner }}
+
+    - name: Checkout repo
+      uses: funkincrew/ci-checkout@v6
+      with:
+        submodules: 'recursive'
+        token: ${{ steps.app_token.outputs.token }}
+
+    - name: Config haxelib
+      run: |
+        haxelib --never newrepo
+        echo "HAXEPATH=$(haxelib config)" >> "$GITHUB_ENV"
+
+    - name: Restore cached dependencies
+      id: cache-hmm
+      uses: actions/cache@v4
+      with:
+        path: .haxelib
+        key: haxe-hmm-${{ runner.os }}-${{ hashFiles('**/hmm.json') }}
+
+    - if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }}
+      name: Install dependencies
+      run: |
+        haxelib --global run hmm install
+
+    - if: ${{ matrix.target != 'html5' }}
+      name: Restore hxcpp cache
+      uses: actions/cache@v4
+      with:
+        path: /usr/share/hxcpp
+        key: haxe-hxcpp-${{ runner.os }}-${{ github.ref_name }}-${{ github.sha }}
+        restore-keys: haxe-hxcpp-${{ runner.os }}-${{ github.ref_name }}
+
+    - name: Build game
+      run: |
+        haxelib run lime build ${{ matrix.target }} -v -release --times -DGITHUB_BUILD
+      timeout-minutes: 120
+
+    - name: Upload build artifacts
+      uses: ./.github/actions/upload-itch
+      with:
+        butler-key: ${{ secrets.BUTLER_API_KEY}}
+        target: ${{ matrix.target }}
diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
deleted file mode 100644
index e217d1f18..000000000
--- a/.github/workflows/build-shit.yml
+++ /dev/null
@@ -1,132 +0,0 @@
-name: build-upload
-on:
-  workflow_dispatch:
-  push:
-
-jobs:
-  create-nightly-html5:
-    runs-on: [self-hosted, linux]
-    container: ubuntu:23.10
-    steps:
-      - name: Install tools missing in container
-        run: |
-          apt update
-          apt install -y sudo git curl unzip
-      - name: Fix git config on posix runner
-        # this can't be {{ github.workspace }} because that's not docker-aware
-        run: |
-          git config --global --add safe.directory $GITHUB_WORKSPACE
-      - name: Get checkout token
-        uses: actions/create-github-app-token@v1
-        id: app_token
-        with:
-          app-id: ${{ vars.APP_ID }}
-          private-key: ${{ secrets.APP_PEM }}
-          owner: ${{ github.repository_owner }}
-      - name: Checkout repo
-        uses: funkincrew/ci-checkout@v6
-        with:
-          submodules: 'recursive'
-          token: ${{ steps.app_token.outputs.token }}
-      - name: Install Haxe, dependencies
-        uses: ./.github/actions/setup-haxeshit
-      - name: Install native dependencies
-        run: |
-          apt install -y \
-            libx11-dev libxi-dev libxext-dev libxinerama-dev libxrandr-dev \
-            libgl-dev libgl1-mesa-dev \
-            libasound2-dev
-      - name: Build game
-        run: |
-          haxelib run lime build html5 -release --times -DGITHUB_BUILD
-      - name: Upload build artifacts
-        uses: ./.github/actions/upload-itch
-        with:
-          butler-key: ${{ secrets.BUTLER_API_KEY}}
-          build-dir: export/release/html5/bin
-          target: html5
-  create-nightly-win:
-    runs-on: [self-hosted, windows]
-    defaults:
-      run:
-        shell: bash
-    steps:
-      - name: Get checkout token
-        uses: actions/create-github-app-token@v1
-        id: app_token
-        with:
-          app-id: ${{ vars.APP_ID }}
-          private-key: ${{ secrets.APP_PEM }}
-          owner: ${{ github.repository_owner }}
-      - name: Checkout repo
-        uses: funkincrew/ci-checkout@v6
-        with:
-          submodules: 'recursive'
-          token: ${{ steps.app_token.outputs.token }}
-      - name: Install Haxe, dependencies
-        uses: ./.github/actions/setup-haxeshit
-      - name: Setup build cache
-        run: |
-          mkdir -p ${{ runner.temp }}/hxcpp_cache
-      - name: Restore build cache
-        id: cache-build-win
-        uses: actions/cache@v4
-        with:
-          path: |
-            export
-            ${{ runner.temp }}/hxcpp_cache
-          key: ${{ runner.os }}-build-win-${{ github.ref_name }}
-      - name: Build game
-        run: |
-          haxelib run lime build windows -v -release -DGITHUB_BUILD
-        env:
-          HXCPP_COMPILE_CACHE: "${{ runner.temp }}\\hxcpp_cache"
-      - name: Upload build artifacts
-        uses: ./.github/actions/upload-itch
-        with:
-          butler-key: ${{ secrets.BUTLER_API_KEY }}
-          build-dir: export/release/windows/bin
-          target: win
-  create-nightly-mac:
-    runs-on: [self-hosted, macos]
-    steps:
-      - name: Fix git config on posix runner
-        # this can't be {{ github.workspace }} because that's not docker-aware
-        run: |
-          git config --global --add safe.directory $GITHUB_WORKSPACE
-      - name: Get checkout token
-        uses: actions/create-github-app-token@v1
-        id: app_token
-        with:
-          app-id: ${{ vars.APP_ID }}
-          private-key: ${{ secrets.APP_PEM }}
-          owner: ${{ github.repository_owner }}
-      - name: Checkout repo
-        uses: funkincrew/ci-checkout@v6
-        with:
-          submodules: 'recursive'
-          token: ${{ steps.app_token.outputs.token }}
-      - name: Install Haxe, dependencies
-        uses: ./.github/actions/setup-haxeshit
-      - name: Setup build cache
-        run: |
-          mkdir -p ${{ runner.temp }}/hxcpp_cache
-      - name: Restore build cache
-        id: cache-build-win
-        uses: actions/cache@v4
-        with:
-          path: |
-            export
-            ${{ runner.temp }}/hxcpp_cache
-          key: ${{ runner.os }}-build-mac-${{ github.ref_name }}
-      - name: Build game
-        run: |
-          haxelib run lime build macos -release --times -DGITHUB_BUILD
-        env:
-          HXCPP_COMPILE_CACHE: "${{ runner.temp }}/hxcpp_cache"
-      - name: Upload build artifacts
-        uses: ./.github/actions/upload-itch
-        with:
-          butler-key: ${{ secrets.BUTLER_API_KEY}}
-          build-dir: export/release/macos/bin
-          target: macos
diff --git a/.github/workflows/cancel-merged-branches.yml b/.github/workflows/cancel-merged-branches.yml
new file mode 100644
index 000000000..f66f9647b
--- /dev/null
+++ b/.github/workflows/cancel-merged-branches.yml
@@ -0,0 +1,38 @@
+name: Cancel queued workflows on PR merge
+
+on:
+  pull_request:
+    types:
+      - closed
+
+jobs:
+
+  cancel_stuff:
+    if: github.event.pull_request.merged == true
+    runs-on: build-set
+    permissions:
+      actions: write
+
+    steps:
+    - name: Cancel queued workflows for ${{ github.event.pull_request.head.ref }}
+      uses: actions/github-script@v7
+      with:
+        result-encoding: string
+        retries: 3
+        script: |
+          let branch_workflows = await github.rest.actions.listWorkflowRuns({
+            owner: context.repo.owner,
+            repo: context.repo.repo,
+            workflow_id: "build-shit.yml",
+            status: "queued",
+            branch: "${{ github.event.pull_request.head.ref }}"
+          });
+          let runs = branch_workflows.data.workflow_runs;
+          runs.forEach((run) => {
+            github.rest.actions.cancelWorkflowRun({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              run_id: run.id
+            });
+          });
+          console.log(runs);
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 13a1862d2..c28bebeab 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -96,6 +96,11 @@
       "target": "windows",
       "args": ["-debug", "-DFORCE_DEBUG_VERSION"]
     },
+    {
+      "label": "Linux / Debug",
+      "target": "linux",
+      "args": ["-debug", "-DFORCE_DEBUG_VERSION"]
+    },
     {
       "label": "HashLink / Debug",
       "target": "hl",
@@ -130,6 +135,11 @@
         "-DFORCE_DEBUG_VERSION"
       ]
     },
+    {
+      "label": "Windows / Debug (Straight to Play - 2hot)",
+      "target": "windows",
+      "args": ["-debug", "-DSONG=2hot", "-DFORCE_DEBUG_VERSION"]
+    },
     {
       "label": "HashLink / Debug (Straight to Play - Bopeebo Normal)",
       "target": "hl",
diff --git a/Project.xml b/Project.xml
index c0da3c89a..db338d32a 100644
--- a/Project.xml
+++ b/Project.xml
@@ -45,6 +45,7 @@
 		<library name="week6" preload="true" />
 		<library name="week7" preload="true" />
 		<library name="weekend1" preload="true" />
+		<library name="videos" preload="true" />
 	</section>
 	<section if="NO_PRELOAD_ALL">
 		<library name="songs" preload="false" />
@@ -58,10 +59,13 @@
 		<library name="week6" preload="false" />
 		<library name="week7" preload="false" />
 		<library name="weekend1" preload="false" />
+		<library name="videos" preload="false" />
 	</section>
 	<library name="art" preload="false" />
 	<assets path="assets/songs" library="songs" exclude="*.fla|*.ogg|*.wav" if="web" />
 	<assets path="assets/songs" library="songs" exclude="*.fla|*.mp3|*.wav" unless="web" />
+	<!-- Videos go in their own library because web never needs to preload them, they can just be streamed. -->
+	<assets path="assets/videos" library="videos" />
 	<assets path="assets/shared" library="shared" exclude="*.fla|*.ogg|*.wav" if="web" />
 	<assets path="assets/shared" library="shared" exclude="*.fla|*.mp3|*.wav" unless="web" />
 	<assets path="assets/tutorial" library="tutorial" exclude="*.fla|*.ogg|*.wav" if="web" />
@@ -119,7 +123,7 @@
 	<haxelib name="flixel-text-input" /> <!-- Improved text field rendering for HaxeUI -->
 	<haxelib name="polymod" /> <!-- Modding framework -->
 	<haxelib name="flxanimate" /> <!-- Texture atlas rendering -->
-	<haxelib name="hxCodec" if="desktop" /> <!-- Video playback -->
+	<haxelib name="hxCodec" if="desktop" unless="hl" /> <!-- Video playback -->
 
 	<haxelib name="json2object" /> <!-- JSON parsing -->
 	<haxelib name="thx.semver" /> <!-- Version string handling -->
@@ -183,6 +187,7 @@
 	<haxedef name="haxeui_focus_out_on_click" />
 	<!-- Required to use haxe.ui.backend.flixel.UIState with build macros. -->
 	<haxedef name="haxeui_dont_impose_base_class" />
+	<haxedef name="HARDCODED_CREDITS" />
 
 	<!-- Skip the Intro -->
 	<section if="debug">
diff --git a/assets b/assets
index 485243fdd..837a8639b 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 485243fdd44acbc4db6a97ec7bf10a8b18350be9
+Subproject commit 837a8639bd7abe4aa8786dc3790e8d4576f04f28
diff --git a/build/Dockerfile b/build/Dockerfile
new file mode 100644
index 000000000..88b44f7a6
--- /dev/null
+++ b/build/Dockerfile
@@ -0,0 +1,185 @@
+FROM ubuntu:mantic
+
+ARG haxe_version=4.3.4
+ARG haxelib_version=4.1.0
+ARG neko_version=2.3.0
+
+# prepare runner
+ENV GITHUB_HOME="/github/home"
+
+RUN <<EOF
+mkdir -p "$GITHUB_HOME"
+mkdir -p /opt
+mkdir -p /usr/share/hxcpp
+mkdir -p /usr/local/bin
+chmod -R 777 /opt
+chmod -R 777 /usr/share
+chmod -R 777 /usr/local/bin
+EOF
+
+
+# Prepare Ubuntu
+# https://github.com/actions/runner-images/blob/main/images/ubuntu/scripts/build/configure-environment.sh
+# https://github.com/actions/runner-images/blob/main/images/ubuntu/scripts/build/configure-system.sh
+RUN <<EOF
+echo 'vm.max_map_count=262144' | tee -a /etc/sysctl.conf
+echo 'fs.inotify.max_user_watches=655360' | tee -a /etc/sysctl.conf
+echo 'fs.inotify.max_user_instances=1280' | tee -a /etc/sysctl.conf
+EOF
+
+ENV DEBIAN_FRONTEND="noninteractive"
+
+# Prepare APT
+RUN <<EOF
+cat <<EOC >> /etc/apt/apt.conf.d/10apt-autoremove
+APT::Get::AutomaticRemove "0";
+APT::Get::HideAutoRemove "1";
+EOC
+
+echo <<EOC >> /etc/apt/apt.conf.d/80retries
+"APT::Acquire::Retries \"10\";"
+EOC
+
+echo <<EOC >> /etc/apt/apt.conf.d/90assumeyes
+"APT::Get::Assume-Yes \"true\";"
+EOC
+EOF
+
+# Prepare apt-fast
+RUN <<EOF
+apt-get update
+apt-get install -y --no-install-recommends software-properties-common
+add-apt-repository -y ppa:apt-fast/stable
+apt-get -y install apt-fast
+echo debconf apt-fast/maxdownloads string 8 | debconf-set-selections
+echo debconf apt-fast/dlflag boolean true | debconf-set-selections
+echo debconf apt-fast/aptmanager string apt-get | debconf-set-selections
+EOF
+
+# Base packages
+# https://github.com/actions/runner-images/blob/main/images/ubuntu/toolsets/toolset-2204.json#L114
+RUN <<EOF
+apt-fast install -y --no-install-recommends \
+  ca-certificates \
+  bzip2 curl g++ gcc make jq tar unzip wget \
+  sudo git openssh-client
+EOF
+
+# Prepare git
+RUN <<EOF
+cat <<EOC >> /etc/gitconfig
+[safe]
+  directory = *
+EOC
+
+ssh-keyscan -t rsa,ecdsa,ed25519 github.com >> /etc/ssh/ssh_known_hosts
+ssh-keyscan -t rsa,ecdsa,ed25519 ravy.dev >> /etc/ssh/ssh_known_hosts
+EOF
+
+# Haxe native dependencies
+RUN <<EOF
+apt-fast install -y --no-install-recommends \
+  libc6-dev libffi-dev \
+  libx11-dev libxi-dev libxext-dev libxinerama-dev libxrandr-dev \
+  libgl-dev libgl1-mesa-dev \
+  libasound2-dev \
+  libvlc-dev libvlccore-dev
+EOF
+
+# Janky libffi.6 fix
+RUN <<EOF
+ln -s \
+  /usr/lib/x86_64-linux-gnu/libffi.so.8 \
+  /usr/lib/x86_64-linux-gnu/libffi.so.6 \
+  || true
+EOF
+
+# neko
+# https://github.com/HaxeFoundation/neko/releases/download/v2-3-0/neko-2.3.0-linux64.tar.gz
+RUN <<EOF
+neko_url=$(curl https://api.github.com/repos/HaxeFoundation/neko/releases -sfL \
+  | jq '.[] | select(.name == "'"$neko_version"'")' \
+  | jq '.assets[] | select(.name | endswith("linux64.tar.gz"))' \
+  | jq -r '.browser_download_url')
+curl -sfL "$neko_url" | tar -xz -C /usr/local
+EOF
+
+RUN <<EOF
+neko_path="$(find /usr/local -maxdepth 1 -type d -name 'neko*')"
+ln -s "$neko_path" /usr/local/neko
+EOF
+
+ENV NEKOPATH="/usr/local/neko"
+ENV LD_LIBRARY_PATH="$NEKOPATH:$LD_LIBRARY_PATH"
+ENV PATH="$NEKOPATH:$PATH"
+
+# haxe
+# https://github.com/HaxeFoundation/haxe/releases/download/4.0.5/haxe-4.0.5-linux64.tar.gz
+RUN <<EOF
+haxe_url=$(curl https://api.github.com/repos/HaxeFoundation/haxe/releases -sfL \
+  | jq '.[] | select(.name == "'"$haxe_version"'")' \
+  | jq '.assets[] | select(.name | endswith("linux64.tar.gz"))' \
+  | jq -r '.browser_download_url')
+curl -sfL "$haxe_url" | tar -xz -C /usr/local
+EOF
+
+RUN <<EOF
+haxe_path="$(find /usr/local -maxdepth 1 -type d -name 'haxe*')"
+ln -s "$haxe_path" /usr/local/haxe
+EOF
+
+ENV HAXEPATH="/usr/local/haxe"
+ENV HAXE_STD_PATH="$HAXEPATH/std"
+ENV PATH="$HAXEPATH:$PATH"
+
+# haxelib
+RUN <<EOF
+HOME=/etc haxelib setup "$HAXEPATH/lib"
+haxelib --global --never install haxelib $haxelib_version
+haxelib --global --never git haxelib https://github.com/HaxeFoundation/haxelib.git master
+haxelib --global --never install hmm
+EOF
+
+# hxcpp
+ENV HXCPP_COMPILE_CACHE="/usr/share/hxcpp"
+ENV HXCPP_CACHE_MB="4096"
+
+# Clean up
+# https://github.com/actions/runner-images/blob/main/images/ubuntu/scripts/build/cleanup.sh
+RUN <<EOF
+rm -r /var/cache/apt/apt-fast
+apt-get clean
+if [ -d /var/lib/apt/lists ]; then
+  rm -rf /var/lib/apt/lists/*
+fi
+
+if [ -d /tmp ]; then
+  rm -rf /tmp/*
+fi
+if [ -d /root/.cache ]; then
+  rm -rf /root/.cache
+fi
+
+if command -v journalctl; then
+  journalctl --rotate
+  journalctl --vacuum-time=1s
+fi
+if [ -d /var/log ]; then
+  find /var/log -type f -regex ".*\.gz$" -delete
+  find /var/log -type f -regex ".*\.[0-9]$" -delete
+  find /var/log/ -type f -exec cp /dev/null {} \;
+fi
+if [ -f /usr/local/bin/invoke_tests ]; then
+  rm -rf /usr/local/bin/invoke_tests
+fi
+EOF
+
+# Print debug info
+RUN <<EOF
+echo "/root"
+ls -la /root
+cat /root/.haxelib && echo
+
+id
+env
+EOF
diff --git a/hmm.json b/hmm.json
index 0dfe88ded..8c07023c7 100644
--- a/hmm.json
+++ b/hmm.json
@@ -104,7 +104,7 @@
       "name": "lime",
       "type": "git",
       "dir": null,
-      "ref": "1359fe6ad52e91175dc636a516d460bd54ea22ed",
+      "ref": "43ebebdd8119936b99f23407057025c7849c5f5b",
       "url": "https://github.com/FunkinCrew/lime"
     },
     {
@@ -139,7 +139,7 @@
       "name": "openfl",
       "type": "git",
       "dir": null,
-      "ref": "f229d76361c7e31025a048fe7909847f75bb5d5e",
+      "ref": "228c1b5063911e2ad75cef6e3168ef0a4b9f9134",
       "url": "https://github.com/FunkinCrew/openfl"
     },
     {
diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx
index 996e2367e..94f41cea4 100644
--- a/source/funkin/Highscore.hx
+++ b/source/funkin/Highscore.hx
@@ -59,6 +59,7 @@ abstract Tallies(RawTallies)
         totalNotes: 0,
         totalNotesHit: 0,
         maxCombo: 0,
+        score: 0,
         isNewHighscore: false
       }
   }
@@ -81,6 +82,9 @@ typedef RawTallies =
   var good:Int;
   var sick:Int;
   var maxCombo:Int;
+
+  var score:Int;
+
   var isNewHighscore:Bool;
 
   /**
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 6ea77ec18..9b842bc13 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -261,6 +261,35 @@ class InitState extends FlxState
       return;
     }
 
+    // TODO: Rework loading behavior so we don't have to do this.
+    switch (songId)
+    {
+      case 'tutorial' | 'bopeebo' | 'fresh' | 'dadbattle':
+        Paths.setCurrentLevel('week1');
+        PlayStatePlaylist.campaignId = 'week1';
+      case 'spookeez' | 'south' | 'monster':
+        Paths.setCurrentLevel('week2');
+        PlayStatePlaylist.campaignId = 'week2';
+      case 'pico' | 'philly-nice' | 'blammed':
+        Paths.setCurrentLevel('week3');
+        PlayStatePlaylist.campaignId = 'week3';
+      case 'high' | 'satin-panties' | 'milf':
+        Paths.setCurrentLevel('week4');
+        PlayStatePlaylist.campaignId = 'week4';
+      case 'cocoa' | 'eggnog' | 'winter-horrorland':
+        Paths.setCurrentLevel('week5');
+        PlayStatePlaylist.campaignId = 'week5';
+      case 'senpai' | 'roses' | 'thorns':
+        Paths.setCurrentLevel('week6');
+        PlayStatePlaylist.campaignId = 'week6';
+      case 'ugh' | 'guns' | 'stress':
+        Paths.setCurrentLevel('week7');
+        PlayStatePlaylist.campaignId = 'week7';
+      case 'darnell' | 'lit-up' | '2hot' | 'blazin':
+        Paths.setCurrentLevel('weekend1');
+        PlayStatePlaylist.campaignId = 'weekend1';
+    }
+
     LoadingState.loadPlayState(
       {
         targetSong: songData,
@@ -283,6 +312,10 @@ class InitState extends FlxState
       return;
     }
 
+    // TODO: Rework loading behavior so we don't have to do this.
+    Paths.setCurrentLevel(levelId);
+    PlayStatePlaylist.campaignId = levelId;
+
     PlayStatePlaylist.playlistSongIds = currentLevel.getSongs();
     PlayStatePlaylist.isStoryMode = true;
     PlayStatePlaylist.campaignScore = 0;
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index d2c4833f2..54a4b7acf 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -9,7 +9,7 @@ import openfl.utils.Assets as OpenFlAssets;
  */
 class Paths
 {
-  static var currentLevel:String;
+  static var currentLevel:Null<String> = null;
 
   public static function setCurrentLevel(name:String):Void
   {
@@ -113,7 +113,7 @@ class Paths
 
   public static function videos(key:String, ?library:String):String
   {
-    return getPath('videos/$key.${Constants.EXT_VIDEO}', BINARY, library);
+    return getPath('videos/$key.${Constants.EXT_VIDEO}', BINARY, library ?? 'videos');
   }
 
   public static function voices(song:String, ?suffix:String = ''):String
diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 0df290feb..df05cc3ef 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -223,11 +223,12 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     // already paused before we lost focus.
     if (_lostFocus && !_alreadyPaused)
     {
+      trace('Resuming audio (${this._label}) on focus!');
       resume();
     }
     else
     {
-      trace('Not resuming audio on focus!');
+      trace('Not resuming audio (${this._label}) on focus!');
     }
     _lostFocus = false;
   }
@@ -265,10 +266,16 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
   @:allow(flixel.sound.FlxSoundGroup)
   override function updateTransform():Void
   {
-    _transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end
-      (group != null ? group.volume : 1) * _volume * _volumeAdjust;
+    if (_transform != null)
+    {
+      _transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end
+        (group != null ? group.volume : 1) * _volume * _volumeAdjust;
+    }
 
-    if (_channel != null) _channel.soundTransform = _transform;
+    if (_channel != null)
+    {
+      _channel.soundTransform = _transform;
+    }
   }
 
   public function clone():FunkinSound
@@ -315,6 +322,13 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
       }
     }
 
+    if (FlxG.sound.music != null)
+    {
+      FlxG.sound.music.fadeTween?.cancel();
+      FlxG.sound.music.stop();
+      FlxG.sound.music.kill();
+    }
+
     if (params?.mapTimeChanges ?? true)
     {
       var songMusicData:Null<SongMusicData> = SongRegistry.instance.parseMusicData(key);
@@ -329,13 +343,6 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
       }
     }
 
-    if (FlxG.sound.music != null)
-    {
-      FlxG.sound.music.fadeTween?.cancel();
-      FlxG.sound.music.stop();
-      FlxG.sound.music.kill();
-    }
-
     var music = FunkinSound.load(Paths.music('$key/$key'), params?.startingVolume ?? 1.0, params.loop ?? true, false, true);
     if (music != null)
     {
@@ -391,10 +398,16 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
       sound._label = 'unknown';
     }
 
+    if (autoPlay) sound.play();
     sound.volume = volume;
     sound.group = FlxG.sound.defaultSoundGroup;
     sound.persist = true;
-    if (autoPlay) sound.play();
+
+    // Make sure to add the sound to the list.
+    // If it's already in, it won't get re-added.
+    // If it's not in the list (it gets removed by FunkinSound.playMusic()),
+    // it will get re-added (then if this was called by playMusic(), removed again)
+    FlxG.sound.list.add(sound);
 
     // Call onLoad() because the sound already loaded
     if (onLoad != null && sound._sound != null) onLoad();
@@ -402,10 +415,16 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     return sound;
   }
 
+  @:nullSafety(Off)
   public override function destroy():Void
   {
     // trace('[FunkinSound] Destroying sound "${this._label}"');
     super.destroy();
+    if (fadeTween != null)
+    {
+      fadeTween.cancel();
+      fadeTween = null;
+    }
     FlxTween.cancelTweensOf(this);
     this._label = 'unknown';
   }
diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx
index 020d5f5bb..5fc2abe0e 100644
--- a/source/funkin/audio/SoundGroup.hx
+++ b/source/funkin/audio/SoundGroup.hx
@@ -150,7 +150,7 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
   /**
    * Stop all the sounds in the group.
    */
-  public function stop()
+  public function stop():Void
   {
     if (members != null)
     {
@@ -160,7 +160,7 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
     }
   }
 
-  public override function destroy()
+  public override function destroy():Void
   {
     stop();
     super.destroy();
@@ -178,9 +178,14 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
 
   function get_time():Float
   {
-    if (getFirstAlive() != null) return getFirstAlive().time;
+    if (getFirstAlive() != null)
+    {
+      return getFirstAlive().time;
+    }
     else
+    {
       return 0;
+    }
   }
 
   function set_time(time:Float):Float
@@ -195,16 +200,26 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
 
   function get_playing():Bool
   {
-    if (getFirstAlive() != null) return getFirstAlive().playing;
+    if (getFirstAlive() != null)
+    {
+      return getFirstAlive().playing;
+    }
     else
+    {
       return false;
+    }
   }
 
   function get_volume():Float
   {
-    if (getFirstAlive() != null) return getFirstAlive().volume;
+    if (getFirstAlive() != null)
+    {
+      return getFirstAlive().volume;
+    }
     else
+    {
       return 1;
+    }
   }
 
   // in PlayState, adjust the code so that it only mutes the player1 vocal tracks?
diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx
index 91054cfb0..5037ee1d0 100644
--- a/source/funkin/audio/VoicesGroup.hx
+++ b/source/funkin/audio/VoicesGroup.hx
@@ -159,10 +159,18 @@ class VoicesGroup extends SoundGroup
 
   public override function destroy():Void
   {
-    playerVoices.destroy();
-    playerVoices = null;
-    opponentVoices.destroy();
-    opponentVoices = null;
+    if (playerVoices != null)
+    {
+      playerVoices.destroy();
+      playerVoices = null;
+    }
+
+    if (opponentVoices != null)
+    {
+      opponentVoices.destroy();
+      opponentVoices = null;
+    }
+
     super.destroy();
   }
 }
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 7419d9425..118516bec 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -325,12 +325,3 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     }
   }
 }
-
-/**
- * A pair of a file name and its contents.
- */
-typedef JsonFile =
-{
-  fileName:String,
-  contents:String
-};
diff --git a/source/funkin/data/JsonFile.hx b/source/funkin/data/JsonFile.hx
new file mode 100644
index 000000000..421ffc22f
--- /dev/null
+++ b/source/funkin/data/JsonFile.hx
@@ -0,0 +1,10 @@
+package funkin.data;
+
+/**
+ * A pair of a file name and its contents.
+ */
+typedef JsonFile =
+{
+  fileName:String,
+  contents:String
+};
diff --git a/source/funkin/data/freeplay/AlbumData.hx b/source/funkin/data/freeplay/AlbumData.hx
index 265a01fce..ca851376d 100644
--- a/source/funkin/data/freeplay/AlbumData.hx
+++ b/source/funkin/data/freeplay/AlbumData.hx
@@ -1,5 +1,7 @@
 package funkin.data.freeplay;
 
+import funkin.data.animation.AnimationData;
+
 /**
  * A type definition for the data for an album of songs.
  * It includes things like what graphics to display in Freeplay.
@@ -33,4 +35,11 @@ typedef AlbumData =
    * The album title will be displayed below the album art in Freeplay.
    */
   public var albumTitleAsset:String;
+
+  /**
+   * An optional array of animations for the album title.
+   */
+  @:optional
+  @:default([])
+  public var albumTitleAnimations:Array<AnimationData>;
 }
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index 4fdf5d0df..277dcd9e1 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -427,7 +427,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return ScriptedSong.listScriptClasses();
   }
 
-  function loadEntryMetadataFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
+  function loadEntryMetadataFile(id:String, ?variation:String):Null<JsonFile>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
     var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}');
@@ -442,7 +442,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return {fileName: entryFilePath, contents: rawJson};
   }
 
-  function loadMusicDataFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
+  function loadMusicDataFile(id:String, ?variation:String):Null<JsonFile>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
     var entryFilePath:String = Paths.file('music/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.json');
@@ -460,7 +460,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return openfl.Assets.exists(entryFilePath);
   }
 
-  function loadEntryChartFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
+  function loadEntryChartFile(id:String, ?variation:String):Null<JsonFile>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
     var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-chart${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}');
diff --git a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
index 5394bce1a..5ab2df837 100644
--- a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
+++ b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
@@ -137,7 +137,8 @@ class FlxAtlasSprite extends FlxAnimate
     anim.callback = function(_, frame:Int) {
       var offset = loop ? 0 : -1;
 
-      if (frame == (anim.getFrameLabel(id).duration + offset) + anim.getFrameLabel(id).index)
+      var frameLabel = anim.getFrameLabel(id);
+      if (frame == (frameLabel.duration + offset) + frameLabel.index)
       {
         if (loop)
         {
diff --git a/source/funkin/graphics/shaders/GaussianBlurShader.hx b/source/funkin/graphics/shaders/GaussianBlurShader.hx
index 81167655b..cecfdab80 100644
--- a/source/funkin/graphics/shaders/GaussianBlurShader.hx
+++ b/source/funkin/graphics/shaders/GaussianBlurShader.hx
@@ -20,6 +20,6 @@ class GaussianBlurShader extends FlxRuntimeShader
   public function setAmount(value:Float):Void
   {
     this.amount = value;
-    this.setFloat("amount", amount);
+    this.setFloat("_amount", amount);
   }
 }
diff --git a/source/funkin/graphics/shaders/Grayscale.hx b/source/funkin/graphics/shaders/Grayscale.hx
index 6673ace24..fbd0970e5 100644
--- a/source/funkin/graphics/shaders/Grayscale.hx
+++ b/source/funkin/graphics/shaders/Grayscale.hx
@@ -17,6 +17,6 @@ class Grayscale extends FlxRuntimeShader
   public function setAmount(value:Float):Void
   {
     amount = value;
-    this.setFloat("amount", amount);
+    this.setFloat("_amount", amount);
   }
 }
diff --git a/source/funkin/graphics/shaders/HSVShader.hx b/source/funkin/graphics/shaders/HSVShader.hx
index 733bbca7f..2dfdac2c9 100644
--- a/source/funkin/graphics/shaders/HSVShader.hx
+++ b/source/funkin/graphics/shaders/HSVShader.hx
@@ -20,7 +20,7 @@ class HSVShader extends FlxRuntimeShader
 
   function set_hue(value:Float):Float
   {
-    this.setFloat('hue', value);
+    this.setFloat('_hue', value);
     this.hue = value;
 
     return this.hue;
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index 78f660d3f..62860ee0f 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -240,8 +240,8 @@ class PolymodHandler
   {
     return {
       assetLibraryPaths: [
-        'default' => 'preload', 'shared' => 'shared', 'songs' => 'songs', 'tutorial' => 'tutorial', 'week1' => 'week1',      'week2' => 'week2',
-            'week3' => 'week3',   'week4' => 'week4', 'week5' => 'week5',       'week6' => 'week6', 'week7' => 'week7', 'weekend1' => 'weekend1',
+        'default' => 'preload', 'shared' => 'shared', 'songs' => 'songs', 'videos' => 'videos', 'tutorial' => 'tutorial', 'week1' => 'week1',
+        'week2' => 'week2', 'week3' => 'week3', 'week4' => 'week4', 'week5' => 'week5', 'week6' => 'week6', 'week7' => 'week7', 'weekend1' => 'weekend1',
       ],
       coreAssetRedirect: CORE_FOLDER,
     }
diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx
index fd58d0fad..c262c311d 100644
--- a/source/funkin/modding/events/ScriptEventDispatcher.hx
+++ b/source/funkin/modding/events/ScriptEventDispatcher.hx
@@ -8,7 +8,12 @@ import funkin.modding.IScriptedClass;
  */
 class ScriptEventDispatcher
 {
-  public static function callEvent(target:IScriptedClass, event:ScriptEvent):Void
+  /**
+   * Invoke the given event hook on the given scripted class.
+   * @param target The target class to call script hooks on.
+   * @param event The event, which determines the script hook to call and provides parameters for it.
+   */
+  public static function callEvent(target:Null<IScriptedClass>, event:ScriptEvent):Void
   {
     if (target == null || event == null) return;
 
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index a1796e912..e7b128385 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -3,18 +3,18 @@ package funkin.play;
 import flixel.FlxG;
 import flixel.FlxObject;
 import flixel.FlxSprite;
-import funkin.audio.FunkinSound;
+import flixel.input.touch.FlxTouch;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
+import funkin.audio.FunkinSound;
 import funkin.graphics.FunkinSprite;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.character.BaseCharacter;
-import funkin.play.PlayState;
-import funkin.util.MathUtil;
 import funkin.ui.freeplay.FreeplayState;
 import funkin.ui.MusicBeatSubState;
 import funkin.ui.story.StoryMenuState;
+import funkin.util.MathUtil;
 import openfl.utils.Assets;
 
 /**
@@ -23,13 +23,14 @@ import openfl.utils.Assets;
  *
  * The newest implementation uses a substate, which prevents having to reload the song and stage each reset.
  */
+@:nullSafety
 class GameOverSubState extends MusicBeatSubState
 {
   /**
    * The currently active GameOverSubState.
    * There should be only one GameOverSubState in existance at a time, we can use a singleton.
    */
-  public static var instance:GameOverSubState = null;
+  public static var instance:Null<GameOverSubState> = null;
 
   /**
    * Which alternate animation on the character to use.
@@ -37,7 +38,7 @@ class GameOverSubState extends MusicBeatSubState
    * For example, playing a different animation when BF dies in Week 4
    * or Pico dies in Weekend 1.
    */
-  public static var animationSuffix:String = "";
+  public static var animationSuffix:String = '';
 
   /**
    * Which alternate game over music to use.
@@ -45,17 +46,19 @@ class GameOverSubState extends MusicBeatSubState
    * For example, the bf-pixel script sets this to `-pixel`
    * and the pico-playable script sets this to `Pico`.
    */
-  public static var musicSuffix:String = "";
+  public static var musicSuffix:String = '';
 
   /**
    * Which alternate "blue ball" sound effect to use.
    */
-  public static var blueBallSuffix:String = "";
+  public static var blueBallSuffix:String = '';
+
+  static var blueballed:Bool = false;
 
   /**
    * The boyfriend character.
    */
-  var boyfriend:BaseCharacter;
+  var boyfriend:Null<BaseCharacter> = null;
 
   /**
    * The invisible object in the scene which the camera focuses on.
@@ -82,7 +85,8 @@ class GameOverSubState extends MusicBeatSubState
 
   var transparent:Bool;
 
-  final CAMERA_ZOOM_DURATION:Float = 0.5;
+  static final CAMERA_ZOOM_DURATION:Float = 0.5;
+
   var targetCameraZoom:Float = 1.0;
 
   public function new(params:GameOverParams)
@@ -91,6 +95,8 @@ class GameOverSubState extends MusicBeatSubState
 
     this.isChartingMode = params?.isChartingMode ?? false;
     transparent = params.transparent;
+
+    cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1);
   }
 
   /**
@@ -101,14 +107,15 @@ class GameOverSubState extends MusicBeatSubState
     animationSuffix = '';
     musicSuffix = '';
     blueBallSuffix = '';
+    blueballed = false;
   }
 
-  override public function create():Void
+  public override function create():Void
   {
     if (instance != null)
     {
       // TODO: Do something in this case? IDK.
-      trace('WARNING: GameOverSubState instance already exists. This should not happen.');
+      FlxG.log.warn('WARNING: GameOverSubState instance already exists. This should not happen.');
     }
     instance = this;
 
@@ -121,7 +128,7 @@ class GameOverSubState extends MusicBeatSubState
     var playState = PlayState.instance;
 
     // Add a black background to the screen.
-    var bg = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
+    var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
     // We make this transparent so that we can see the stage underneath during debugging,
     // but it's normally opaque.
     bg.alpha = transparent ? 0.25 : 1.0;
@@ -138,21 +145,10 @@ class GameOverSubState extends MusicBeatSubState
       boyfriend.isDead = true;
       add(boyfriend);
       boyfriend.resetCharacter();
-
-      // Assign a camera follow point to the boyfriend's position.
-      cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1);
-      cameraFollowPoint.x = boyfriend.getGraphicMidpoint().x;
-      cameraFollowPoint.y = boyfriend.getGraphicMidpoint().y;
-      var offsets:Array<Float> = boyfriend.getDeathCameraOffsets();
-      cameraFollowPoint.x += offsets[0];
-      cameraFollowPoint.y += offsets[1];
-      add(cameraFollowPoint);
-
-      FlxG.camera.target = null;
-      FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.01);
-      targetCameraZoom = PlayState?.instance?.currentStage?.camZoom * boyfriend.getDeathCameraZoom();
     }
 
+    setCameraTarget();
+
     //
     // Set up the audio
     //
@@ -161,6 +157,27 @@ class GameOverSubState extends MusicBeatSubState
     Conductor.instance.update(0);
   }
 
+  @:nullSafety(Off)
+  function setCameraTarget():Void
+  {
+    // Assign a camera follow point to the boyfriend's position.
+    cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1);
+    cameraFollowPoint.x = boyfriend.getGraphicMidpoint().x;
+    cameraFollowPoint.y = boyfriend.getGraphicMidpoint().y;
+    var offsets:Array<Float> = boyfriend.getDeathCameraOffsets();
+    cameraFollowPoint.x += offsets[0];
+    cameraFollowPoint.y += offsets[1];
+    add(cameraFollowPoint);
+
+    FlxG.camera.target = null;
+    FlxG.camera.follow(cameraFollowPoint, LOCKON, Constants.DEFAULT_CAMERA_FOLLOW_RATE / 2);
+    targetCameraZoom = (PlayState?.instance?.currentStage?.camZoom ?? 1.0) * boyfriend.getDeathCameraZoom();
+  }
+
+  /**
+   * Forcibly reset the camera zoom level to that of the current stage.
+   * This prevents camera zoom events from adversely affecting the game over state.
+   */
   public function resetCameraZoom():Void
   {
     // Apply camera zoom level from stage data.
@@ -175,7 +192,7 @@ class GameOverSubState extends MusicBeatSubState
     {
       hasStartedAnimation = true;
 
-      if (PlayState.instance.isMinimalMode)
+      if (boyfriend == null || PlayState.instance.isMinimalMode)
       {
         // Play the "blue balled" sound. May play a variant if one has been assigned.
         playBlueBalledSFX();
@@ -205,10 +222,10 @@ class GameOverSubState extends MusicBeatSubState
     // MOBILE ONLY: Restart the level when tapping Boyfriend.
     if (FlxG.onMobile)
     {
-      var touch = FlxG.touches.getFirst();
+      var touch:FlxTouch = FlxG.touches.getFirst();
       if (touch != null)
       {
-        if (touch.overlaps(boyfriend))
+        if (boyfriend == null || touch.overlaps(boyfriend))
         {
           confirmDeath();
         }
@@ -228,7 +245,7 @@ class GameOverSubState extends MusicBeatSubState
       blueballed = false;
       PlayState.instance.deathCounter = 0;
       // PlayState.seenCutscene = false; // old thing...
-      gameOverMusic.stop();
+      if (gameOverMusic != null) gameOverMusic.stop();
 
       if (isChartingMode)
       {
@@ -238,11 +255,11 @@ class GameOverSubState extends MusicBeatSubState
       }
       else if (PlayStatePlaylist.isStoryMode)
       {
-        FlxG.switchState(() -> new StoryMenuState());
+        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new StoryMenuState(sticker)));
       }
       else
       {
-        FlxG.switchState(() -> new FreeplayState());
+        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(sticker)));
       }
     }
 
@@ -252,7 +269,7 @@ class GameOverSubState extends MusicBeatSubState
       // This enables the stepHit and beatHit events.
       Conductor.instance.update(gameOverMusic.time);
     }
-    else
+    else if (boyfriend != null)
     {
       if (PlayState.instance.isMinimalMode)
       {
@@ -299,7 +316,7 @@ class GameOverSubState extends MusicBeatSubState
       isEnding = true;
       startDeathMusic(1.0, true); // isEnding changes this function's behavior.
 
-      if (PlayState.instance.isMinimalMode) {}
+      if (PlayState.instance.isMinimalMode || boyfriend == null) {}
       else
       {
         boyfriend.playAnimation('deathConfirm' + animationSuffix, true);
@@ -313,7 +330,7 @@ class GameOverSubState extends MusicBeatSubState
           FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true);
           PlayState.instance.needsReset = true;
 
-          if (PlayState.instance.isMinimalMode) {}
+          if (PlayState.instance.isMinimalMode || boyfriend == null) {}
           else
           {
             // Readd Boyfriend to the stage.
@@ -332,7 +349,7 @@ class GameOverSubState extends MusicBeatSubState
     }
   }
 
-  public override function dispatchEvent(event:ScriptEvent)
+  public override function dispatchEvent(event:ScriptEvent):Void
   {
     super.dispatchEvent(event);
 
@@ -345,11 +362,11 @@ class GameOverSubState extends MusicBeatSubState
    */
   function resolveMusicPath(suffix:String, starting:Bool = false, ending:Bool = false):Null<String>
   {
-    var basePath = 'gameplay/gameover/gameOver';
-    if (starting) basePath += 'Start';
-    else if (ending) basePath += 'End';
+    var basePath:String = 'gameplay/gameover/gameOver';
+    if (ending) basePath += 'End';
+    else if (starting) basePath += 'Start';
 
-    var musicPath = Paths.music(basePath + suffix);
+    var musicPath:String = Paths.music(basePath + suffix);
     while (!Assets.exists(musicPath) && suffix.length > 0)
     {
       suffix = suffix.split('-').slice(0, -1).join('-');
@@ -362,23 +379,26 @@ class GameOverSubState extends MusicBeatSubState
 
   /**
    * Starts the death music at the appropriate volume.
-   * @param startingVolume
+   * @param startingVolume The initial volume for the music.
+   * @param force Whether or not to force the music to restart.
    */
   public function startDeathMusic(startingVolume:Float = 1, force:Bool = false):Void
   {
-    var musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding);
-    var onComplete = null;
+    var musicPath:Null<String> = resolveMusicPath(musicSuffix, isStarting, isEnding);
+    var onComplete:() -> Void = () -> {};
+
     if (isStarting)
     {
       if (musicPath == null)
       {
+        // Looked for starting music and didn't find it. Use middle music instead.
         isStarting = false;
         musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding);
       }
       else
       {
         onComplete = function() {
-          isStarting = false;
+          isStarting = true;
           // We need to force to ensure that the non-starting music plays.
           startDeathMusic(1.0, true);
         };
@@ -387,13 +407,16 @@ class GameOverSubState extends MusicBeatSubState
 
     if (musicPath == null)
     {
-      trace('Could not find game over music!');
+      FlxG.log.warn('[GAMEOVER] Could not find game over music at path ($musicPath)!');
       return;
     }
     else if (gameOverMusic == null || !gameOverMusic.playing || force)
     {
       if (gameOverMusic != null) gameOverMusic.stop();
+
       gameOverMusic = FunkinSound.load(musicPath);
+      if (gameOverMusic == null) return;
+
       gameOverMusic.volume = startingVolume;
       gameOverMusic.looped = !(isEnding || isStarting);
       gameOverMusic.onComplete = onComplete;
@@ -406,13 +429,11 @@ class GameOverSubState extends MusicBeatSubState
     }
   }
 
-  static var blueballed:Bool = false;
-
   /**
    * Play the sound effect that occurs when
    * boyfriend's testicles get utterly annihilated.
    */
-  public static function playBlueBalledSFX()
+  public static function playBlueBalledSFX():Void
   {
     blueballed = true;
     if (Assets.exists(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix)))
@@ -431,7 +452,7 @@ class GameOverSubState extends MusicBeatSubState
    * Week 7-specific hardcoded behavior, to play a custom death quote.
    * TODO: Make this a module somehow.
    */
-  function playJeffQuote()
+  function playJeffQuote():Void
   {
     var randomCensor:Array<Int> = [];
 
@@ -446,20 +467,27 @@ class GameOverSubState extends MusicBeatSubState
     });
   }
 
-  public override function destroy()
+  public override function destroy():Void
   {
     super.destroy();
-    if (gameOverMusic != null) gameOverMusic.stop();
-    gameOverMusic = null;
+    if (gameOverMusic != null)
+    {
+      gameOverMusic.stop();
+      gameOverMusic = null;
+    }
+    blueballed = false;
     instance = null;
   }
 
   public override function toString():String
   {
-    return "GameOverSubState";
+    return 'GameOverSubState';
   }
 }
 
+/**
+ * Parameters used to instantiate a GameOverSubState.
+ */
 typedef GameOverParams =
 {
   var isChartingMode:Bool;
diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index f16aa00d8..fc1d01377 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -12,6 +12,7 @@ import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
 import funkin.audio.FunkinSound;
 import funkin.data.song.SongRegistry;
+import funkin.ui.freeplay.FreeplayState;
 import funkin.graphics.FunkinSprite;
 import funkin.play.cutscene.VideoCutscene;
 import funkin.play.PlayState;
@@ -72,8 +73,8 @@ class PauseSubState extends MusicBeatSubState
    */
   static final PAUSE_MENU_ENTRIES_VIDEO_CUTSCENE:Array<PauseMenuEntry> = [
     {text: 'Resume', callback: resume},
-    {text: 'Restart Cutscene', callback: restartVideoCutscene},
     {text: 'Skip Cutscene', callback: skipVideoCutscene},
+    {text: 'Restart Cutscene', callback: restartVideoCutscene},
     {text: 'Exit to Menu', callback: quitToMenu},
   ];
 
@@ -230,7 +231,7 @@ class PauseSubState extends MusicBeatSubState
    */
   function startPauseMusic():Void
   {
-    var pauseMusicPath:String = Paths.music('breakfast$musicSuffix');
+    var pauseMusicPath:String = Paths.music('breakfast$musicSuffix/breakfast$musicSuffix');
     pauseMusic = FunkinSound.load(pauseMusicPath, true, true);
 
     if (pauseMusic == null)
@@ -440,7 +441,7 @@ class PauseSubState extends MusicBeatSubState
         var entries:Array<PauseMenuEntry> = [];
         if (PlayState.instance.currentChart != null)
         {
-          var difficultiesInVariation = PlayState.instance.currentSong.listDifficulties(PlayState.instance.currentChart.variation);
+          var difficultiesInVariation = PlayState.instance.currentSong.listDifficulties(PlayState.instance.currentChart.variation, true);
           trace('DIFFICULTIES: ${difficultiesInVariation}');
           for (difficulty in difficultiesInVariation)
           {
@@ -567,6 +568,8 @@ class PauseSubState extends MusicBeatSubState
     PlayStatePlaylist.campaignDifficulty = difficulty;
     PlayState.instance.currentDifficulty = PlayStatePlaylist.campaignDifficulty;
 
+    FreeplayState.rememberedDifficulty = difficulty;
+
     PlayState.instance.needsReset = true;
 
     state.close();
@@ -658,7 +661,7 @@ class PauseSubState extends MusicBeatSubState
     }
     else
     {
-      state.openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.freeplay.FreeplayState(null, sticker)));
+      state.openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(null, sticker)));
     }
   }
 
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index fd6463bb1..d3246228b 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -245,20 +245,26 @@ class PlayState extends MusicBeatSubState
   /**
    * The current camera zoom level without any modifiers applied.
    */
-  public var currentCameraZoom:Float = FlxCamera.defaultZoom * 1.05;
+  public var currentCameraZoom:Float = FlxCamera.defaultZoom;
 
   /**
-   * currentCameraZoom is increased every beat, and lerped back to this value every frame, creating a smooth 'zoom-in' effect.
-   * Defaults to 1.05, but may be larger or smaller depending on the current stage.
-   * Tweened via the `ZoomCamera` song event in direct mode.
+   * Multiplier for currentCameraZoom for camera bops.
+   * Lerped back to 1.0x every frame.
    */
-  public var defaultCameraZoom:Float = FlxCamera.defaultZoom * 1.05;
+  public var cameraBopMultiplier:Float = 1.0;
 
   /**
-   * Camera zoom applied on top of currentCameraZoom.
-   * Tweened via the `ZoomCamera` song event in additive mode.
+   * Default camera zoom for the current stage.
+   * If we aren't in a stage, just use the default zoom (1.05x).
    */
-  public var additiveCameraZoom:Float = 0;
+  public var stageZoom(get, never):Float;
+
+  function get_stageZoom():Float
+  {
+    if (currentStage != null) return currentStage.camZoom;
+    else
+      return FlxCamera.defaultZoom * 1.05;
+  }
 
   /**
    * The current HUD camera zoom level.
@@ -268,16 +274,18 @@ class PlayState extends MusicBeatSubState
   public var defaultHUDCameraZoom:Float = FlxCamera.defaultZoom * 1.0;
 
   /**
-   * Intensity of the gameplay camera zoom.
-   * @default `1.5%`
+   * Camera bop intensity multiplier.
+   * Applied to cameraBopMultiplier on camera bops (usually every beat).
+   * @default `101.5%`
    */
-  public var cameraZoomIntensity:Float = Constants.DEFAULT_ZOOM_INTENSITY;
+  public var cameraBopIntensity:Float = Constants.DEFAULT_BOP_INTENSITY;
 
   /**
    * Intensity of the HUD camera zoom.
+   * Need to make this a multiplier later. Just shoving in 0.015 for now so it doesn't break.
    * @default `3.0%`
    */
-  public var hudCameraZoomIntensity:Float = Constants.DEFAULT_ZOOM_INTENSITY * 2.0;
+  public var hudCameraZoomIntensity:Float = 0.015 * 2.0;
 
   /**
    * How many beats (quarter notes) between camera zooms.
@@ -728,6 +736,10 @@ class PlayState extends MusicBeatSubState
     #end
 
     initialized = true;
+
+    // This step ensures z-indexes are applied properly,
+    // and it's important to call it last so all elements get affected.
+    refresh();
   }
 
   public override function draw():Void
@@ -822,9 +834,12 @@ class PlayState extends MusicBeatSubState
       inputSpitter = [];
 
       // Reset music properly.
-      FlxG.sound.music.time = startTimestamp - Conductor.instance.instrumentalOffset;
-      FlxG.sound.music.pitch = playbackRate;
-      FlxG.sound.music.pause();
+      if (FlxG.sound.music != null)
+      {
+        FlxG.sound.music.time = startTimestamp - Conductor.instance.instrumentalOffset;
+        FlxG.sound.music.pitch = playbackRate;
+        FlxG.sound.music.pause();
+      }
 
       if (!overrideMusic)
       {
@@ -840,7 +855,7 @@ class PlayState extends MusicBeatSubState
       vocals.pause();
       vocals.time = 0;
 
-      FlxG.sound.music.volume = 1;
+      if (FlxG.sound.music != null) FlxG.sound.music.volume = 1;
       vocals.volume = 1;
       vocals.playerVolume = 1;
       vocals.opponentVolume = 1;
@@ -857,8 +872,8 @@ class PlayState extends MusicBeatSubState
       regenNoteData();
 
       // Reset camera zooming
-      cameraZoomIntensity = Constants.DEFAULT_ZOOM_INTENSITY;
-      hudCameraZoomIntensity = Constants.DEFAULT_ZOOM_INTENSITY * 2.0;
+      cameraBopIntensity = Constants.DEFAULT_BOP_INTENSITY;
+      hudCameraZoomIntensity = (cameraBopIntensity - 1.0) * 2.0;
       cameraZoomRate = Constants.DEFAULT_ZOOM_RATE;
 
       health = Constants.HEALTH_STARTING;
@@ -953,11 +968,12 @@ class PlayState extends MusicBeatSubState
     if (health > Constants.HEALTH_MAX) health = Constants.HEALTH_MAX;
     if (health < Constants.HEALTH_MIN) health = Constants.HEALTH_MIN;
 
-    // Lerp the camera zoom towards the target level.
-    if (subState == null)
+    // Apply camera zoom + multipliers.
+    if (subState == null && cameraZoomRate > 0.0 && !isInCutscene)
     {
-      currentCameraZoom = FlxMath.lerp(defaultCameraZoom, currentCameraZoom, 0.95);
-      FlxG.camera.zoom = currentCameraZoom + additiveCameraZoom;
+      cameraBopMultiplier = FlxMath.lerp(1.0, cameraBopMultiplier, 0.95); // Lerp bop multiplier back to 1.0x
+      var zoomPlusBop = currentCameraZoom * cameraBopMultiplier; // Apply camera bop multiplier.
+      FlxG.camera.zoom = zoomPlusBop; // Actually apply the zoom to the camera.
 
       camHUD.zoom = FlxMath.lerp(defaultHUDCameraZoom, camHUD.zoom, 0.95);
     }
@@ -967,6 +983,7 @@ class PlayState extends MusicBeatSubState
       FlxG.watch.addQuick('bfAnim', currentStage.getBoyfriend().getCurrentAnimation());
     }
     FlxG.watch.addQuick('health', health);
+    FlxG.watch.addQuick('cameraBopIntensity', cameraBopIntensity);
 
     // TODO: Add a song event for Handle GF dance speed.
 
@@ -1363,15 +1380,15 @@ class PlayState extends MusicBeatSubState
       // activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING);
     }
 
-    // Only zoom camera if we are zoomed by less than 35%.
+    // Only bop camera if zoom level is below 135%
     if (Preferences.zoomCamera
-      && FlxG.camera.zoom < (1.35 * defaultCameraZoom)
+      && FlxG.camera.zoom < (1.35 * FlxCamera.defaultZoom)
       && cameraZoomRate > 0
       && Conductor.instance.currentBeat % cameraZoomRate == 0)
     {
-      // Zoom camera in (1.5%)
-      currentCameraZoom += cameraZoomIntensity * defaultCameraZoom;
-      // Hud zooms double (3%)
+      // Set zoom multiplier for camera bop.
+      cameraBopMultiplier = cameraBopIntensity;
+      // HUD camera zoom still uses old system. To change. (+3%)
       camHUD.zoom += hudCameraZoomIntensity * defaultHUDCameraZoom;
     }
     // trace('Not bopping camera: ${FlxG.camera.zoom} < ${(1.35 * defaultCameraZoom)} && ${cameraZoomRate} > 0 && ${Conductor.instance.currentBeat} % ${cameraZoomRate} == ${Conductor.instance.currentBeat % cameraZoomRate}}');
@@ -1534,10 +1551,11 @@ class PlayState extends MusicBeatSubState
   function loadStage(id:String):Void
   {
     currentStage = StageRegistry.instance.fetchEntry(id);
-    currentStage.revive(); // Stages are killed and props destroyed when the PlayState is destroyed to save memory.
 
     if (currentStage != null)
     {
+      currentStage.revive(); // Stages are killed and props destroyed when the PlayState is destroyed to save memory.
+
       // Actually create and position the sprites.
       var event:ScriptEvent = new ScriptEvent(CREATE, false);
       ScriptEventDispatcher.callEvent(currentStage, event);
@@ -1562,12 +1580,11 @@ class PlayState extends MusicBeatSubState
   {
     if (PlayState.instance.isMinimalMode) return;
     // Apply camera zoom level from stage data.
-    defaultCameraZoom = currentStage.camZoom;
-    currentCameraZoom = defaultCameraZoom;
+    currentCameraZoom = stageZoom;
     FlxG.camera.zoom = currentCameraZoom;
 
-    // Reset additive zoom.
-    additiveCameraZoom = 0;
+    // Reset bop multiplier.
+    cameraBopMultiplier = 1.0;
   }
 
   /**
@@ -1720,8 +1737,6 @@ class PlayState extends MusicBeatSubState
       playerStrumline.fadeInArrows();
       opponentStrumline.fadeInArrows();
     }
-
-    this.refresh();
   }
 
   /**
@@ -2222,8 +2237,8 @@ class PlayState extends MusicBeatSubState
         holdNote.handledMiss = true;
 
         // Mute vocals and play miss animation, but don't penalize.
-        vocals.playerVolume = 0;
-        if (currentStage != null && currentStage.getBoyfriend() != null) currentStage.getBoyfriend().playSingAnimation(holdNote.noteData.getDirection(), true);
+        // vocals.playerVolume = 0;
+        // if (currentStage != null && currentStage.getBoyfriend() != null) currentStage.getBoyfriend().playSingAnimation(holdNote.noteData.getDirection(), true);
       }
     }
   }
@@ -2377,13 +2392,6 @@ class PlayState extends MusicBeatSubState
 
     // Display the combo meter and add the calculation to the score.
     popUpScore(note, event.score, event.judgement, event.healthChange);
-
-    if (note.isHoldNote && note.holdNoteSprite != null)
-    {
-      playerStrumline.playNoteHoldCover(note.holdNoteSprite);
-    }
-
-    vocals.playerVolume = 1;
   }
 
   /**
@@ -2441,7 +2449,8 @@ class PlayState extends MusicBeatSubState
     if (Highscore.tallies.combo != 0)
     {
       // Break the combo.
-      Highscore.tallies.combo = comboPopUps.displayCombo(0);
+      if (Highscore.tallies.combo >= 10) comboPopUps.displayCombo(0);
+      Highscore.tallies.combo = 0;
     }
 
     if (playSound)
@@ -2568,32 +2577,38 @@ class PlayState extends MusicBeatSubState
    */
   function popUpScore(daNote:NoteSprite, score:Int, daRating:String, healthChange:Float):Void
   {
-    vocals.playerVolume = 1;
-
     if (daRating == 'miss')
     {
       // If daRating is 'miss', that means we made a mistake and should not continue.
-      trace('[WARNING] popUpScore judged a note as a miss!');
+      FlxG.log.warn('popUpScore judged a note as a miss!');
       // TODO: Remove this.
-      comboPopUps.displayRating('miss');
+      // comboPopUps.displayRating('miss');
       return;
     }
 
+    vocals.playerVolume = 1;
+
     var isComboBreak = false;
     switch (daRating)
     {
       case 'sick':
         Highscore.tallies.sick += 1;
+        Highscore.tallies.totalNotesHit++;
         isComboBreak = Constants.JUDGEMENT_SICK_COMBO_BREAK;
       case 'good':
         Highscore.tallies.good += 1;
+        Highscore.tallies.totalNotesHit++;
         isComboBreak = Constants.JUDGEMENT_GOOD_COMBO_BREAK;
       case 'bad':
         Highscore.tallies.bad += 1;
+        Highscore.tallies.totalNotesHit++;
         isComboBreak = Constants.JUDGEMENT_BAD_COMBO_BREAK;
       case 'shit':
         Highscore.tallies.shit += 1;
+        Highscore.tallies.totalNotesHit++;
         isComboBreak = Constants.JUDGEMENT_SHIT_COMBO_BREAK;
+      default:
+        FlxG.log.error('Wuh? Buh? Guh? Note hit judgement was $daRating!');
     }
 
     health += healthChange;
@@ -2601,18 +2616,18 @@ class PlayState extends MusicBeatSubState
     if (isComboBreak)
     {
       // Break the combo, but don't increment tallies.misses.
-      Highscore.tallies.combo = comboPopUps.displayCombo(0);
+      if (Highscore.tallies.combo >= 10) comboPopUps.displayCombo(0);
+      Highscore.tallies.combo = 0;
     }
     else
     {
       Highscore.tallies.combo++;
-      Highscore.tallies.totalNotesHit++;
       if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
     }
 
     playerStrumline.hitNote(daNote, !isComboBreak);
 
-    if (daRating == "sick")
+    if (daRating == 'sick')
     {
       playerStrumline.playNoteSplash(daNote.noteData.getDirection());
     }
@@ -2658,6 +2673,13 @@ class PlayState extends MusicBeatSubState
     }
     comboPopUps.displayRating(daRating);
     if (Highscore.tallies.combo >= 10 || Highscore.tallies.combo == 0) comboPopUps.displayCombo(Highscore.tallies.combo);
+
+    if (daNote.isHoldNote && daNote.holdNoteSprite != null)
+    {
+      playerStrumline.playNoteHoldCover(daNote.holdNoteSprite);
+    }
+
+    vocals.playerVolume = 1;
   }
 
   /**
@@ -2724,7 +2746,7 @@ class PlayState extends MusicBeatSubState
    */
   public function endSong(rightGoddamnNow:Bool = false):Void
   {
-    FlxG.sound.music.volume = 0;
+    if (FlxG.sound.music != null) FlxG.sound.music.volume = 0;
     vocals.volume = 0;
     mayPauseGame = false;
 
@@ -2742,6 +2764,8 @@ class PlayState extends MusicBeatSubState
 
     deathCounter = 0;
 
+    var isNewHighscore = false;
+
     if (currentSong != null && currentSong.validScore)
     {
       // crackhead double thingie, sets whether was new highscore, AND saves the song!
@@ -2766,17 +2790,20 @@ class PlayState extends MusicBeatSubState
       // adds current song data into the tallies for the level (story levels)
       Highscore.talliesLevel = Highscore.combineTallies(Highscore.tallies, Highscore.talliesLevel);
 
-      if (Save.instance.isSongHighScore(currentSong.id, currentDifficulty, data))
+      if (!isPracticeMode && !isBotPlayMode && Save.instance.isSongHighScore(currentSong.id, currentDifficulty, data))
       {
         Save.instance.setSongScore(currentSong.id, currentDifficulty, data);
         #if newgrounds
         NGio.postScore(score, currentSong.id);
         #end
+        isNewHighscore = true;
       }
     }
 
     if (PlayStatePlaylist.isStoryMode)
     {
+      isNewHighscore = false;
+
       PlayStatePlaylist.campaignScore += songScore;
 
       // Pop the next song ID from the list.
@@ -2785,18 +2812,6 @@ class PlayState extends MusicBeatSubState
 
       if (targetSongId == null)
       {
-        FunkinSound.playMusic('freakyMenu',
-          {
-            overrideExisting: true,
-            restartTrack: false
-          });
-
-        // transIn = FlxTransitionableState.defaultTransIn;
-        // transOut = FlxTransitionableState.defaultTransOut;
-
-        // TODO: Rework week unlock logic.
-        // StoryMenuState.weekUnlocked[Std.int(Math.min(storyWeek + 1, StoryMenuState.weekUnlocked.length - 1))] = true;
-
         if (currentSong.validScore)
         {
           NGio.unlockMedal(60961);
@@ -2826,6 +2841,7 @@ class PlayState extends MusicBeatSubState
             #if newgrounds
             NGio.postScore(score, 'Level ${PlayStatePlaylist.campaignId}');
             #end
+            isNewHighscore = true;
           }
         }
 
@@ -2837,11 +2853,11 @@ class PlayState extends MusicBeatSubState
         {
           if (rightGoddamnNow)
           {
-            moveToResultsScreen();
+            moveToResultsScreen(isNewHighscore);
           }
           else
           {
-            zoomIntoResultsScreen();
+            zoomIntoResultsScreen(isNewHighscore);
           }
         }
       }
@@ -2854,7 +2870,7 @@ class PlayState extends MusicBeatSubState
         FlxTransitionableState.skipNextTransIn = true;
         FlxTransitionableState.skipNextTransOut = true;
 
-        FlxG.sound.music.stop();
+        if (FlxG.sound.music != null) FlxG.sound.music.stop();
         vocals.stop();
 
         // TODO: Softcode this cutscene.
@@ -2902,11 +2918,11 @@ class PlayState extends MusicBeatSubState
       {
         if (rightGoddamnNow)
         {
-          moveToResultsScreen();
+          moveToResultsScreen(isNewHighscore);
         }
         else
         {
-          zoomIntoResultsScreen();
+          zoomIntoResultsScreen(isNewHighscore);
         }
       }
     }
@@ -2980,7 +2996,7 @@ class PlayState extends MusicBeatSubState
   /**
    * Play the camera zoom animation and then move to the results screen once it's done.
    */
-  function zoomIntoResultsScreen():Void
+  function zoomIntoResultsScreen(isNewHighscore:Bool):Void
   {
     trace('WENT TO RESULTS SCREEN!');
 
@@ -3037,7 +3053,7 @@ class PlayState extends MusicBeatSubState
         {
           ease: FlxEase.expoIn,
           onComplete: function(_) {
-            moveToResultsScreen();
+            moveToResultsScreen(isNewHighscore);
           }
         });
     });
@@ -3046,7 +3062,7 @@ class PlayState extends MusicBeatSubState
   /**
    * Move to the results screen right goddamn now.
    */
-  function moveToResultsScreen():Void
+  function moveToResultsScreen(isNewHighscore:Bool):Void
   {
     persistentUpdate = false;
     vocals.stop();
@@ -3058,7 +3074,24 @@ class PlayState extends MusicBeatSubState
       {
         storyMode: PlayStatePlaylist.isStoryMode,
         title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'),
-        tallies: talliesToUse,
+        scoreData:
+          {
+            score: PlayStatePlaylist.isStoryMode ? PlayStatePlaylist.campaignScore : songScore,
+            tallies:
+              {
+                sick: talliesToUse.sick,
+                good: talliesToUse.good,
+                bad: talliesToUse.bad,
+                shit: talliesToUse.shit,
+                missed: talliesToUse.missed,
+                combo: talliesToUse.combo,
+                maxCombo: talliesToUse.maxCombo,
+                totalNotesHit: talliesToUse.totalNotesHit,
+                totalNotes: talliesToUse.totalNotes,
+              },
+            accuracy: Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes,
+          },
+        isNewHighscore: isNewHighscore
       });
     res.camera = camHUD;
     openSubState(res);
@@ -3096,6 +3129,15 @@ class PlayState extends MusicBeatSubState
     FlxG.camera.focusOn(cameraFollowPoint.getPosition());
   }
 
+  /**
+   * Sets the camera follow point's position and tweens the camera there.
+   */
+  public function tweenCameraToPosition(?x:Float, ?y:Float, ?duration:Float, ?ease:Null<Float->Float>):Void
+  {
+    cameraFollowPoint.setPosition(x, y);
+    tweenCameraToFollowPoint(duration, ease);
+  }
+
   /**
    * Disables camera following and tweens the camera to the follow point manually.
    */
@@ -3137,38 +3179,24 @@ class PlayState extends MusicBeatSubState
   /**
    * Tweens the camera zoom to the desired amount.
    */
-  public function tweenCameraZoom(?zoom:Float, ?duration:Float, ?directMode:Bool, ?ease:Null<Float->Float>):Void
+  public function tweenCameraZoom(?zoom:Float, ?duration:Float, ?direct:Bool, ?ease:Null<Float->Float>):Void
   {
     // Cancel the current tween if it's active.
     cancelCameraZoomTween();
 
-    var targetZoom = zoom * FlxCamera.defaultZoom;
+    // Direct mode: Set zoom directly.
+    // Stage mode: Set zoom as a multiplier of the current stage's default zoom.
+    var targetZoom = zoom * (direct ? FlxCamera.defaultZoom : stageZoom);
 
-    if (directMode) // Direct mode: Tween defaultCameraZoom for basic "smooth" zooms.
+    if (duration == 0)
     {
-      if (duration == 0)
-      {
-        // Instant zoom. No tween needed.
-        defaultCameraZoom = targetZoom;
-      }
-      else
-      {
-        // Zoom tween! Caching it so we can cancel/pause it later if needed.
-        cameraZoomTween = FlxTween.tween(this, {defaultCameraZoom: targetZoom}, duration, {ease: ease});
-      }
+      // Instant zoom. No tween needed.
+      currentCameraZoom = targetZoom;
     }
-    else // Additive mode: Tween additiveCameraZoom for ease-based zooms.
+    else
     {
-      if (duration == 0)
-      {
-        // Instant zoom. No tween needed.
-        additiveCameraZoom = targetZoom;
-      }
-      else
-      {
-        // Zoom tween! Caching it so we can cancel/pause it later if needed.
-        cameraZoomTween = FlxTween.tween(this, {additiveCameraZoom: targetZoom}, duration, {ease: ease});
-      }
+      // Zoom tween! Caching it so we can cancel/pause it later if needed.
+      cameraZoomTween = FlxTween.tween(this, {currentCameraZoom: targetZoom}, duration, {ease: ease});
     }
   }
 
@@ -3205,7 +3233,10 @@ class PlayState extends MusicBeatSubState
     // Don't go back in time to before the song started.
     targetTimeMs = Math.max(0, targetTimeMs);
 
-    FlxG.sound.music.time = targetTimeMs;
+    if (FlxG.sound.music != null)
+    {
+      FlxG.sound.music.time = targetTimeMs;
+    }
 
     handleSkippedNotes();
     SongEventRegistry.handleSkippedEvents(songEvents, Conductor.instance.songPosition);
diff --git a/source/funkin/play/PlayStatePlaylist.hx b/source/funkin/play/PlayStatePlaylist.hx
index 3b0fb01f6..e47a6288a 100644
--- a/source/funkin/play/PlayStatePlaylist.hx
+++ b/source/funkin/play/PlayStatePlaylist.hx
@@ -5,12 +5,13 @@ package funkin.play;
  *
  * TODO: Add getters/setters for all these properties to validate them.
  */
+@:nullSafety
 class PlayStatePlaylist
 {
   /**
    * Whether the game is currently in Story Mode. If false, we are in Free Play Mode.
    */
-  public static var isStoryMode(default, default):Bool = false;
+  public static var isStoryMode:Bool = false;
 
   /**
    * The loist of upcoming songs to be played.
@@ -31,8 +32,9 @@ class PlayStatePlaylist
 
   /**
    * The internal ID of the current playlist, for example `week4` or `weekend-1`.
+   * @default `null`, used when no playlist is loaded
    */
-  public static var campaignId:String = 'unknown';
+  public static var campaignId:Null<String> = null;
 
   public static var campaignDifficulty:String = Constants.DEFAULT_DIFFICULTY;
 
@@ -45,7 +47,7 @@ class PlayStatePlaylist
     playlistSongIds = [];
     campaignScore = 0;
     campaignTitle = 'UNKNOWN';
-    campaignId = 'unknown';
+    campaignId = null;
     campaignDifficulty = Constants.DEFAULT_DIFFICULTY;
   }
 }
diff --git a/source/funkin/play/ResultScore.hx b/source/funkin/play/ResultScore.hx
new file mode 100644
index 000000000..d5d5a6567
--- /dev/null
+++ b/source/funkin/play/ResultScore.hx
@@ -0,0 +1,140 @@
+package funkin.play;
+
+import flixel.FlxSprite;
+import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
+
+class ResultScore extends FlxTypedSpriteGroup<ScoreNum>
+{
+  public var scoreShit(default, set):Int = 0;
+
+  function set_scoreShit(val):Int
+  {
+    if (group == null || group.members == null) return val;
+    var loopNum:Int = group.members.length - 1;
+    var dumbNumb = Std.parseInt(Std.string(val));
+    var prevNum:ScoreNum;
+
+    while (dumbNumb > 0)
+    {
+      group.members[loopNum].digit = dumbNumb % 10;
+
+      // var funnyNum = group.members[loopNum];
+      // prevNum = group.members[loopNum + 1];
+
+      // if (prevNum != null)
+      // {
+      // funnyNum.x = prevNum.x - (funnyNum.width * 0.7);
+      // }
+
+      // funnyNum.y = (funnyNum.baseY - (funnyNum.height / 2)) + 73;
+      // funnyNum.x = (funnyNum.baseX - (funnyNum.width / 2)) + 450; // this plus value is hand picked lol!
+
+      dumbNumb = Math.floor(dumbNumb / 10);
+      loopNum--;
+    }
+
+    while (loopNum > 0)
+    {
+      group.members[loopNum].digit = 10;
+      loopNum--;
+    }
+
+    return val;
+  }
+
+  public function animateNumbers():Void
+  {
+    for (i in group.members)
+    {
+      i.playAnim();
+    }
+  }
+
+  public function new(x:Float, y:Float, digitCount:Int, scoreShit:Int = 100)
+  {
+    super(x, y);
+
+    for (i in 0...digitCount)
+    {
+      add(new ScoreNum(x + (65 * i), y));
+    }
+
+    this.scoreShit = scoreShit;
+  }
+
+  public function updateScore(scoreNew:Int)
+  {
+    scoreShit = scoreNew;
+  }
+}
+
+class ScoreNum extends FlxSprite
+{
+  public var digit(default, set):Int = 10;
+
+  function set_digit(val):Int
+  {
+    if (val >= 0 && animation.curAnim != null && animation.curAnim.name != numToString[val])
+    {
+      animation.play(numToString[val], true, false, 0);
+      updateHitbox();
+
+      switch (val)
+      {
+        case 1:
+        // offset.x -= 15;
+        case 5:
+        // set offsets
+        // offset.x += 0;
+        // offset.y += 10;
+
+        case 7:
+        // offset.y += 6;
+        case 4:
+        // offset.y += 5;
+        case 9:
+        // offset.y += 5;
+        default:
+          centerOffsets(false);
+      }
+    }
+
+    return digit = val;
+  }
+
+  public function playAnim():Void
+  {
+    animation.play(numToString[digit], true, false, 0);
+  }
+
+  public var baseY:Float = 0;
+  public var baseX:Float = 0;
+
+  var numToString:Array<String> = [
+    "ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "DISABLED"
+  ];
+
+  public function new(x:Float, y:Float)
+  {
+    super(x, y);
+
+    baseY = y;
+    baseX = x;
+
+    frames = Paths.getSparrowAtlas('resultScreen/score-digital-numbers');
+
+    for (i in 0...10)
+    {
+      var stringNum:String = numToString[i];
+      animation.addByPrefix(stringNum, '$stringNum DIGITAL', 24, false);
+    }
+
+    animation.addByPrefix('DISABLED', 'DISABLED', 24, false);
+
+    this.digit = 10;
+
+    animation.play(numToString[digit], true);
+
+    updateHitbox();
+  }
+}
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 821f4ba3c..591020955 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -1,5 +1,6 @@
 package funkin.play;
 
+import funkin.util.MathUtil;
 import funkin.ui.story.StoryMenuState;
 import funkin.graphics.adobeanimate.FlxAtlasSprite;
 import flixel.FlxSprite;
@@ -10,12 +11,15 @@ import flixel.math.FlxPoint;
 import funkin.ui.MusicBeatSubState;
 import flixel.math.FlxRect;
 import flixel.text.FlxBitmapText;
+import funkin.ui.freeplay.FreeplayScore;
 import flixel.tweens.FlxEase;
 import funkin.ui.freeplay.FreeplayState;
 import flixel.tweens.FlxTween;
 import funkin.audio.FunkinSound;
 import flixel.util.FlxGradient;
 import flixel.util.FlxTimer;
+import funkin.save.Save;
+import funkin.save.Save.SaveScoreData;
 import funkin.graphics.shaders.LeftMaskShader;
 import funkin.play.components.TallyCounter;
 
@@ -42,12 +46,15 @@ class ResultState extends MusicBeatSubState
 
   override function create():Void
   {
-    if (params.tallies.sick == params.tallies.totalNotesHit
-      && params.tallies.maxCombo == params.tallies.totalNotesHit) resultsVariation = PERFECT;
-    else if (params.tallies.missed + params.tallies.bad + params.tallies.shit >= params.tallies.totalNotes * 0.50)
-      resultsVariation = SHIT; // if more than half of your song was missed, bad, or shit notes, you get shit ending!
-    else
-      resultsVariation = NORMAL;
+    /*
+      if (params.scoreData.sick == params.scoreData.totalNotesHit
+        && params.scoreData.maxCombo == params.scoreData.totalNotesHit) resultsVariation = PERFECT;
+      else if (params.scoreData.missed + params.scoreData.bad + params.scoreData.shit >= params.scoreData.totalNotes * 0.50)
+        resultsVariation = SHIT; // if more than half of your song was missed, bad, or shit notes, you get shit ending!
+      else
+        resultsVariation = NORMAL;
+     */
+    resultsVariation = NORMAL;
 
     FunkinSound.playMusic('results$resultsVariation',
       {
@@ -73,34 +80,34 @@ class ResultState extends MusicBeatSubState
     bgFlash.visible = false;
     add(bgFlash);
 
-    var bfGfExcellent:FlxAtlasSprite = new FlxAtlasSprite(380, -170, Paths.animateAtlas("resultScreen/resultsBoyfriendExcellent", "shared"));
-    bfGfExcellent.visible = false;
-    add(bfGfExcellent);
+    // var bfGfExcellent:FlxAtlasSprite = new FlxAtlasSprite(380, -170, Paths.animateAtlas("resultScreen/resultsBoyfriendExcellent", "shared"));
+    // bfGfExcellent.visible = false;
+    // add(bfGfExcellent);
+    //
+    // var bfPerfect:FlxAtlasSprite = new FlxAtlasSprite(370, -180, Paths.animateAtlas("resultScreen/resultsBoyfriendPerfect", "shared"));
+    // bfPerfect.visible = false;
+    // add(bfPerfect);
+    //
+    // var bfSHIT:FlxAtlasSprite = new FlxAtlasSprite(0, 20, Paths.animateAtlas("resultScreen/resultsBoyfriendSHIT", "shared"));
+    // bfSHIT.visible = false;
+    // add(bfSHIT);
+    //
+    // bfGfExcellent.anim.onComplete = () -> {
+    // bfGfExcellent.anim.curFrame = 28;
+    // bfGfExcellent.anim.play(); // unpauses this anim, since it's on PlayOnce!
+    // };
+    //
+    // bfPerfect.anim.onComplete = () -> {
+    //  bfPerfect.anim.curFrame = 136;
+    //  bfPerfect.anim.play(); // unpauses this anim, since it's on PlayOnce!
+    // };
+    //
+    // bfSHIT.anim.onComplete = () -> {
+    //  bfSHIT.anim.curFrame = 150;
+    //  bfSHIT.anim.play(); // unpauses this anim, since it's on PlayOnce!
+    // };
 
-    var bfPerfect:FlxAtlasSprite = new FlxAtlasSprite(370, -180, Paths.animateAtlas("resultScreen/resultsBoyfriendPerfect", "shared"));
-    bfPerfect.visible = false;
-    add(bfPerfect);
-
-    var bfSHIT:FlxAtlasSprite = new FlxAtlasSprite(0, 20, Paths.animateAtlas("resultScreen/resultsBoyfriendSHIT", "shared"));
-    bfSHIT.visible = false;
-    add(bfSHIT);
-
-    bfGfExcellent.anim.onComplete = () -> {
-      bfGfExcellent.anim.curFrame = 28;
-      bfGfExcellent.anim.play(); // unpauses this anim, since it's on PlayOnce!
-    };
-
-    bfPerfect.anim.onComplete = () -> {
-      bfPerfect.anim.curFrame = 136;
-      bfPerfect.anim.play(); // unpauses this anim, since it's on PlayOnce!
-    };
-
-    bfSHIT.anim.onComplete = () -> {
-      bfSHIT.anim.curFrame = 150;
-      bfSHIT.anim.play(); // unpauses this anim, since it's on PlayOnce!
-    };
-
-    var gf:FlxSprite = FunkinSprite.createSparrow(500, 300, 'resultScreen/resultGirlfriendGOOD');
+    var gf:FlxSprite = FunkinSprite.createSparrow(625, 325, 'resultScreen/resultGirlfriendGOOD');
     gf.animation.addByPrefix("clap", "Girlfriend Good Anim", 24, false);
     gf.visible = false;
     gf.animation.finishCallback = _ -> {
@@ -130,12 +137,16 @@ class ResultState extends MusicBeatSubState
 
     var diffSpr:String = switch (PlayState.instance.currentDifficulty)
     {
-      case 'EASY':
+      case 'easy':
         'difEasy';
-      case 'NORMAL':
+      case 'normal':
         'difNormal';
-      case 'HARD':
+      case 'hard':
         'difHard';
+      case 'erect':
+        'difErect';
+      case 'nightmare':
+        'difNightmare';
       case _:
         'difNormal';
     }
@@ -178,7 +189,7 @@ class ResultState extends MusicBeatSubState
     scorePopin.visible = false;
     add(scorePopin);
 
-    var highscoreNew:FlxSprite = new FlxSprite(280, 580);
+    var highscoreNew:FlxSprite = new FlxSprite(310, 570);
     highscoreNew.frames = Paths.getSparrowAtlas("resultScreen/highscoreNew");
     highscoreNew.animation.addByPrefix("new", "NEW HIGHSCORE", 24);
     highscoreNew.visible = false;
@@ -195,29 +206,33 @@ class ResultState extends MusicBeatSubState
      * NOTE: We display how many notes were HIT, not how many notes there were in total.
      *
      */
-    var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, params.tallies.totalNotesHit);
+    var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, params.scoreData.tallies.totalNotesHit);
     ratingGrp.add(totalHit);
 
-    var maxCombo:TallyCounter = new TallyCounter(375, hStuf * 4, params.tallies.maxCombo);
+    var maxCombo:TallyCounter = new TallyCounter(375, hStuf * 4, params.scoreData.tallies.maxCombo);
     ratingGrp.add(maxCombo);
 
     hStuf += 2;
     var extraYOffset:Float = 5;
-    var tallySick:TallyCounter = new TallyCounter(230, (hStuf * 5) + extraYOffset, params.tallies.sick, 0xFF89E59E);
+    var tallySick:TallyCounter = new TallyCounter(230, (hStuf * 5) + extraYOffset, params.scoreData.tallies.sick, 0xFF89E59E);
     ratingGrp.add(tallySick);
 
-    var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, params.tallies.good, 0xFF89C9E5);
+    var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, params.scoreData.tallies.good, 0xFF89C9E5);
     ratingGrp.add(tallyGood);
 
-    var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.tallies.bad, 0xFFE6CF8A);
+    var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.scoreData.tallies.bad, 0xFFE6CF8A);
     ratingGrp.add(tallyBad);
 
-    var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, params.tallies.shit, 0xFFE68C8A);
+    var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, params.scoreData.tallies.shit, 0xFFE68C8A);
     ratingGrp.add(tallyShit);
 
-    var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, params.tallies.missed, 0xFFC68AE6);
+    var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, params.scoreData.tallies.missed, 0xFFC68AE6);
     ratingGrp.add(tallyMissed);
 
+    var score:ResultScore = new ResultScore(35, 305, 10, params.scoreData.score);
+    score.visible = false;
+    add(score);
+
     for (ind => rating in ratingGrp.members)
     {
       rating.visible = false;
@@ -233,18 +248,29 @@ class ResultState extends MusicBeatSubState
 
       ratingsPopin.animation.finishCallback = anim -> {
         scorePopin.animation.play("score");
+        scorePopin.animation.finishCallback = anim -> {
+          score.visible = true;
+          score.animateNumbers();
+        };
         scorePopin.visible = true;
 
-        highscoreNew.visible = true;
-        highscoreNew.animation.play("new");
-        FlxTween.tween(highscoreNew, {y: highscoreNew.y + 10}, 0.8, {ease: FlxEase.quartOut});
+        if (params.isNewHighscore)
+        {
+          highscoreNew.visible = true;
+          highscoreNew.animation.play("new");
+          FlxTween.tween(highscoreNew, {y: highscoreNew.y + 10}, 0.8, {ease: FlxEase.quartOut});
+        }
+        else
+        {
+          highscoreNew.visible = false;
+        }
       };
 
       switch (resultsVariation)
       {
-        case SHIT:
-          bfSHIT.visible = true;
-          bfSHIT.playAnimation("");
+        // case SHIT:
+        // bfSHIT.visible = true;
+        // bfSHIT.playAnimation("");
 
         case NORMAL:
           boyfriend.animation.play('fall');
@@ -266,9 +292,9 @@ class ResultState extends MusicBeatSubState
             gf.animation.play('clap', true);
             gf.visible = true;
           });
-        case PERFECT:
-          bfPerfect.visible = true;
-          bfPerfect.playAnimation("");
+        // case PERFECT:
+        //          bfPerfect.visible = true;
+        //          bfPerfect.playAnimation("");
 
         // bfGfExcellent.visible = true;
         // bfGfExcellent.playAnimation("");
@@ -276,8 +302,6 @@ class ResultState extends MusicBeatSubState
       }
     });
 
-    if (params.tallies.isNewHighscore) trace("ITS A NEW HIGHSCORE!!!");
-
     super.create();
   }
 
@@ -365,7 +389,7 @@ class ResultState extends MusicBeatSubState
       }
       else
       {
-        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new FreeplayState(null, sticker)));
+        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(null, sticker)));
       }
     }
 
@@ -393,8 +417,13 @@ typedef ResultsStateParams =
    */
   var title:String;
 
+  /**
+   * Whether the displayed score is a new highscore
+   */
+  var isNewHighscore:Bool;
+
   /**
    * The score, accuracy, and judgements.
    */
-  var tallies:Highscore.Tallies;
+  var scoreData:SaveScoreData;
 };
diff --git a/source/funkin/play/character/AnimateAtlasCharacter.hx b/source/funkin/play/character/AnimateAtlasCharacter.hx
index f1dadf3e2..ed58b92b5 100644
--- a/source/funkin/play/character/AnimateAtlasCharacter.hx
+++ b/source/funkin/play/character/AnimateAtlasCharacter.hx
@@ -192,6 +192,7 @@ class AnimateAtlasCharacter extends BaseCharacter
       if (!this.mainSprite.hasAnimation(prefix))
       {
         FlxG.log.warn('[ATLASCHAR] Animation ${prefix} not found in Animate Atlas ${_data.assetPath}');
+        trace('[ATLASCHAR] Animation ${prefix} not found in Animate Atlas ${_data.assetPath}');
         continue;
       }
       animations.set(anim.name, anim);
diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
index 39fc192a0..724bf0cb9 100644
--- a/source/funkin/play/components/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -85,7 +85,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
     comboSpr.velocity.y -= 150;
     comboSpr.velocity.x += FlxG.random.int(1, 10);
 
-    add(comboSpr);
+    // add(comboSpr);
 
     if (PlayState.instance.currentStageId.startsWith('school'))
     {
diff --git a/source/funkin/play/components/TallyCounter.hx b/source/funkin/play/components/TallyCounter.hx
index 77e6ef4ec..35a8f3f51 100644
--- a/source/funkin/play/components/TallyCounter.hx
+++ b/source/funkin/play/components/TallyCounter.hx
@@ -6,6 +6,8 @@ import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import flixel.math.FlxMath;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
+import flixel.text.FlxText.FlxTextAlign;
+import funkin.util.MathUtil;
 
 /**
  * Numerical counters used next to each judgement in the Results screen.
@@ -13,18 +15,23 @@ import flixel.tweens.FlxTween;
 class TallyCounter extends FlxTypedSpriteGroup<FlxSprite>
 {
   public var curNumber:Float = 0;
-
   public var neededNumber:Int = 0;
+
   public var flavour:Int = 0xFFFFFFFF;
 
-  public function new(x:Float, y:Float, neededNumber:Int = 0, ?flavour:Int = 0xFFFFFFFF)
+  public var align:FlxTextAlign = FlxTextAlign.LEFT;
+
+  public function new(x:Float, y:Float, neededNumber:Int = 0, ?flavour:Int = 0xFFFFFFFF, align:FlxTextAlign = FlxTextAlign.LEFT)
   {
     super(x, y);
 
+    this.align = align;
+
     this.flavour = flavour;
 
     this.neededNumber = neededNumber;
-    drawNumbers();
+
+    if (curNumber == neededNumber) drawNumbers();
   }
 
   var tmr:Float = 0;
@@ -41,6 +48,8 @@ class TallyCounter extends FlxTypedSpriteGroup<FlxSprite>
     var seperatedScore:Array<Int> = [];
     var tempCombo:Int = Math.round(curNumber);
 
+    var fullNumberDigits:Int = Std.int(Math.max(1, Math.ceil(MathUtil.logBase(10, neededNumber))));
+
     while (tempCombo != 0)
     {
       seperatedScore.push(tempCombo % 10);
@@ -55,7 +64,13 @@ class TallyCounter extends FlxTypedSpriteGroup<FlxSprite>
     {
       if (ind >= members.length)
       {
-        var numb:TallyNumber = new TallyNumber(ind * 43, 0, num);
+        var xPos = ind * (43 * this.scale.x);
+        if (this.align == FlxTextAlign.RIGHT)
+        {
+          xPos -= (fullNumberDigits * (43 * this.scale.x));
+        }
+        var numb:TallyNumber = new TallyNumber(xPos, 0, num);
+        numb.scale.set(this.scale.x, this.scale.y);
         add(numb);
         numb.color = flavour;
       }
diff --git a/source/funkin/play/cutscene/VideoCutscene.hx b/source/funkin/play/cutscene/VideoCutscene.hx
index 3da51185f..0939dae38 100644
--- a/source/funkin/play/cutscene/VideoCutscene.hx
+++ b/source/funkin/play/cutscene/VideoCutscene.hx
@@ -67,8 +67,13 @@ class VideoCutscene
     if (!openfl.Assets.exists(filePath))
     {
       // Display a popup.
-      lime.app.Application.current.window.alert('Video file does not exist: ${filePath}', 'Error playing video');
-      return;
+      // lime.app.Application.current.window.alert('Video file does not exist: ${filePath}', 'Error playing video');
+      // return;
+
+      // TODO: After moving videos to their own library,
+      // this function ALWAYS FAILS on web, but the video still plays.
+      // I think that's due to a weird quirk with how OpenFL libraries work.
+      trace('Video file does not exist: ${filePath}');
     }
 
     var rawFilePath = Paths.stripLibrary(filePath);
@@ -311,7 +316,7 @@ class VideoCutscene
           blackScreen = null;
         }
       });
-    FlxTween.tween(FlxG.camera, {zoom: PlayState.instance.defaultCameraZoom}, transitionTime,
+    FlxTween.tween(FlxG.camera, {zoom: PlayState.instance.stageZoom}, transitionTime,
       {
         ease: FlxEase.quadInOut,
         onComplete: function(twn:FlxTween) {
diff --git a/source/funkin/play/cutscene/dialogue/Conversation.hx b/source/funkin/play/cutscene/dialogue/Conversation.hx
index c520c3e25..2c59eaba0 100644
--- a/source/funkin/play/cutscene/dialogue/Conversation.hx
+++ b/source/funkin/play/cutscene/dialogue/Conversation.hx
@@ -23,6 +23,7 @@ import funkin.modding.IScriptedClass.IDialogueScriptedClass;
 import funkin.modding.IScriptedClass.IEventHandler;
 import funkin.play.cutscene.dialogue.DialogueBox;
 import funkin.util.SortUtil;
+import funkin.util.EaseUtil;
 
 /**
  * A high-level handler for dialogue.
@@ -179,7 +180,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl
         if (backdropData.fadeTime > 0.0)
         {
           backdrop.alpha = 0.0;
-          FlxTween.tween(backdrop, {alpha: 1.0}, backdropData.fadeTime, {ease: FlxEase.linear});
+          FlxTween.tween(backdrop, {alpha: 1.0}, backdropData.fadeTime, {ease: EaseUtil.stepped(10)});
         }
         else
         {
@@ -403,6 +404,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl
             type: ONESHOT, // holy shit like the game no way
             startDelay: 0,
             onComplete: (_) -> endOutro(),
+            ease: EaseUtil.stepped(8)
           });
 
         FlxTween.tween(this.music, {volume: 0.0}, outroData.fadeTime);
diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx
index d4ab4f24f..1bcac9ad3 100644
--- a/source/funkin/play/event/FocusCameraSongEvent.hx
+++ b/source/funkin/play/event/FocusCameraSongEvent.hx
@@ -70,80 +70,76 @@ class FocusCameraSongEvent extends SongEvent
 
     if (char == null) char = cast data.value;
 
-    var useTween:Null<Bool> = data.getBool('useTween');
-    if (useTween == null) useTween = false;
     var duration:Null<Float> = data.getFloat('duration');
     if (duration == null) duration = 4.0;
     var ease:Null<String> = data.getString('ease');
-    if (ease == null) ease = 'linear';
+    if (ease == null) ease = 'CLASSIC';
+
+    var currentStage = PlayState.instance.currentStage;
+
+    // Get target position based on char.
+    var targetX:Float = posX;
+    var targetY:Float = posY;
 
     switch (char)
     {
-      case -1: // Position
+      case -1: // Position ("focus" on origin)
         trace('Focusing camera on static position.');
-        var xTarget:Float = posX;
-        var yTarget:Float = posY;
 
-        PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
-      case 0: // Boyfriend
-        // Focus the camera on the player.
-        if (PlayState.instance.currentStage.getBoyfriend() == null)
+      case 0: // Boyfriend (focus on player)
+        if (currentStage.getBoyfriend() == null)
         {
           trace('No BF to focus on.');
           return;
         }
         trace('Focusing camera on player.');
-        var xTarget:Float = PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.x + posX;
-        var yTarget:Float = PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.y + posY;
+        var bfPoint = currentStage.getBoyfriend().cameraFocusPoint;
+        targetX += bfPoint.x;
+        targetY += bfPoint.y;
 
-        PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
-      case 1: // Dad
-        // Focus the camera on the dad.
-        if (PlayState.instance.currentStage.getDad() == null)
+      case 1: // Dad (focus on opponent)
+        if (currentStage.getDad() == null)
         {
           trace('No dad to focus on.');
           return;
         }
-        trace('Focusing camera on dad.');
-        trace(PlayState.instance.currentStage.getDad());
-        var xTarget:Float = PlayState.instance.currentStage.getDad().cameraFocusPoint.x + posX;
-        var yTarget:Float = PlayState.instance.currentStage.getDad().cameraFocusPoint.y + posY;
+        trace('Focusing camera on opponent.');
+        var dadPoint = currentStage.getDad().cameraFocusPoint;
+        targetX += dadPoint.x;
+        targetY += dadPoint.y;
 
-        PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
-      case 2: // Girlfriend
-        // Focus the camera on the girlfriend.
-        if (PlayState.instance.currentStage.getGirlfriend() == null)
+      case 2: // Girlfriend (focus on girlfriend)
+        if (currentStage.getGirlfriend() == null)
         {
           trace('No GF to focus on.');
           return;
         }
         trace('Focusing camera on girlfriend.');
-        var xTarget:Float = PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.x + posX;
-        var yTarget:Float = PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.y + posY;
+        var gfPoint = currentStage.getGirlfriend().cameraFocusPoint;
+        targetX += gfPoint.x;
+        targetY += gfPoint.y;
 
-        PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
       default:
         trace('Unknown camera focus: ' + data);
     }
 
-    if (useTween)
+    // Apply tween based on ease.
+    switch (ease)
     {
-      switch (ease)
-      {
-        case 'INSTANT':
-          PlayState.instance.tweenCameraToFollowPoint(0);
-        default:
-          var durSeconds = Conductor.instance.stepLengthMs * duration / 1000;
-
-          var easeFunction:Null<Float->Float> = Reflect.field(FlxEase, ease);
-          if (easeFunction == null)
-          {
-            trace('Invalid ease function: $ease');
-            return;
-          }
-
-          PlayState.instance.tweenCameraToFollowPoint(durSeconds, easeFunction);
-      }
+      case 'CLASSIC': // Old-school. No ease. Just set follow point.
+        PlayState.instance.cancelCameraFollowTween();
+        PlayState.instance.cameraFollowPoint.setPosition(targetX, targetY);
+      case 'INSTANT': // Instant ease. Duration is automatically 0.
+        PlayState.instance.tweenCameraToPosition(targetX, targetY, 0);
+      default:
+        var durSeconds = Conductor.instance.stepLengthMs * duration / 1000;
+        var easeFunction:Null<Float->Float> = Reflect.field(FlxEase, ease);
+        if (easeFunction == null)
+        {
+          trace('Invalid ease function: $ease');
+          return;
+        }
+        PlayState.instance.tweenCameraToPosition(targetX, targetY, durSeconds, easeFunction);
     }
   }
 
@@ -187,12 +183,6 @@ class FocusCameraSongEvent extends SongEvent
         type: SongEventFieldType.FLOAT,
         units: "px"
       },
-      {
-        name: 'useTween',
-        title: 'Use Tween',
-        type: SongEventFieldType.BOOL,
-        defaultValue: false
-      },
       {
         name: 'duration',
         title: 'Duration',
@@ -208,7 +198,9 @@ class FocusCameraSongEvent extends SongEvent
         type: SongEventFieldType.ENUM,
         keys: [
           'Linear' => 'linear',
-          'Instant' => 'INSTANT',
+          'Sine In' => 'sineIn',
+          'Sine Out' => 'sineOut',
+          'Sine In/Out' => 'sineInOut',
           'Quad In' => 'quadIn',
           'Quad Out' => 'quadOut',
           'Quad In/Out' => 'quadInOut',
@@ -221,15 +213,17 @@ class FocusCameraSongEvent extends SongEvent
           'Quint In' => 'quintIn',
           'Quint Out' => 'quintOut',
           'Quint In/Out' => 'quintInOut',
+          'Expo In' => 'expoIn',
+          'Expo Out' => 'expoOut',
+          'Expo In/Out' => 'expoInOut',
           'Smooth Step In' => 'smoothStepIn',
           'Smooth Step Out' => 'smoothStepOut',
           'Smooth Step In/Out' => 'smoothStepInOut',
-          'Sine In' => 'sineIn',
-          'Sine Out' => 'sineOut',
-          'Sine In/Out' => 'sineInOut',
           'Elastic In' => 'elasticIn',
           'Elastic Out' => 'elasticOut',
           'Elastic In/Out' => 'elasticInOut',
+          'Instant (Ignores duration)' => 'INSTANT',
+          'Classic (Ignores duration)' => 'CLASSIC'
         ]
       }
     ]);
diff --git a/source/funkin/play/event/SetCameraBopSongEvent.hx b/source/funkin/play/event/SetCameraBopSongEvent.hx
index a82577a5f..f3efc04e3 100644
--- a/source/funkin/play/event/SetCameraBopSongEvent.hx
+++ b/source/funkin/play/event/SetCameraBopSongEvent.hx
@@ -50,8 +50,8 @@ class SetCameraBopSongEvent extends SongEvent
     var intensity:Null<Float> = data.getFloat('intensity');
     if (intensity == null) intensity = 1.0;
 
-    PlayState.instance.cameraZoomIntensity = Constants.DEFAULT_ZOOM_INTENSITY * intensity;
-    PlayState.instance.hudCameraZoomIntensity = Constants.DEFAULT_ZOOM_INTENSITY * intensity * 2.0;
+    PlayState.instance.cameraBopIntensity = (Constants.DEFAULT_BOP_INTENSITY - 1.0) * intensity + 1.0;
+    PlayState.instance.hudCameraZoomIntensity = (Constants.DEFAULT_BOP_INTENSITY - 1.0) * intensity * 2.0;
     PlayState.instance.cameraZoomRate = rate;
     trace('Set camera zoom rate to ${PlayState.instance.cameraZoomRate}');
   }
diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx
index b913aebe7..748abda19 100644
--- a/source/funkin/play/event/ZoomCameraSongEvent.hx
+++ b/source/funkin/play/event/ZoomCameraSongEvent.hx
@@ -81,7 +81,6 @@ class ZoomCameraSongEvent extends SongEvent
         PlayState.instance.tweenCameraZoom(zoom, 0, isDirectMode);
       default:
         var durSeconds = Conductor.instance.stepLengthMs * duration / 1000;
-
         var easeFunction:Null<Float->Float> = Reflect.field(FlxEase, ease);
         if (easeFunction == null)
         {
@@ -102,9 +101,9 @@ class ZoomCameraSongEvent extends SongEvent
    * ```
    * {
    *   'zoom': FLOAT, // Target zoom level.
-   *   'duration': FLOAT, // Optional duration in steps.
-   *   'mode': ENUM, // Whether to set additive zoom or direct zoom.
-   *   'ease': ENUM, // Optional easing function.
+   *   'duration': FLOAT, // Duration in steps.
+   *   'mode': ENUM, // Whether zoom is relative to the stage or absolute zoom.
+   *   'ease': ENUM, // Easing function.
    * }
    * @return SongEventSchema
    */
@@ -130,9 +129,9 @@ class ZoomCameraSongEvent extends SongEvent
       {
         name: 'mode',
         title: 'Mode',
-        defaultValue: 'direct',
+        defaultValue: 'stage',
         type: SongEventFieldType.ENUM,
-        keys: ['Additive' => 'additive', 'Direct' => 'direct']
+        keys: ['Stage zoom' => 'stage', 'Absolute zoom' => 'direct']
       },
       {
         name: 'ease',
@@ -142,6 +141,9 @@ class ZoomCameraSongEvent extends SongEvent
         keys: [
           'Linear' => 'linear',
           'Instant' => 'INSTANT',
+          'Sine In' => 'sineIn',
+          'Sine Out' => 'sineOut',
+          'Sine In/Out' => 'sineInOut',
           'Quad In' => 'quadIn',
           'Quad Out' => 'quadOut',
           'Quad In/Out' => 'quadInOut',
@@ -154,15 +156,15 @@ class ZoomCameraSongEvent extends SongEvent
           'Quint In' => 'quintIn',
           'Quint Out' => 'quintOut',
           'Quint In/Out' => 'quintInOut',
+          'Expo In' => 'expoIn',
+          'Expo Out' => 'expoOut',
+          'Expo In/Out' => 'expoInOut',
           'Smooth Step In' => 'smoothStepIn',
           'Smooth Step Out' => 'smoothStepOut',
           'Smooth Step In/Out' => 'smoothStepInOut',
-          'Sine In' => 'sineIn',
-          'Sine Out' => 'sineOut',
-          'Sine In/Out' => 'sineInOut',
           'Elastic In' => 'elasticIn',
           'Elastic Out' => 'elasticOut',
-          'Elastic In/Out' => 'elasticInOut',
+          'Elastic In/Out' => 'elasticInOut'
         ]
       }
     ]);
diff --git a/source/funkin/play/notes/NoteHoldCover.hx b/source/funkin/play/notes/NoteHoldCover.hx
index 52ae97d4f..7bed8a08c 100644
--- a/source/funkin/play/notes/NoteHoldCover.hx
+++ b/source/funkin/play/notes/NoteHoldCover.hx
@@ -77,13 +77,6 @@ class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
   public override function update(elapsed):Void
   {
     super.update(elapsed);
-    if ((!holdNote.alive || holdNote.missedNote) && !glow.animation.curAnim.name.startsWith('holdCoverEnd'))
-    {
-      // If alive is false, the hold note was held to completion.
-      // If missedNote is true, the hold note was "dropped".
-
-      playEnd();
-    }
   }
 
   public function playStart():Void
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 9a6699c43..2b4e09370 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -295,6 +295,11 @@ class Strumline extends FlxSpriteGroup
   {
     if (noteData.length == 0) return;
 
+    // Ensure note data gets reset if the song happens to loop.
+    // NOTE: I had to remove this line because it was causing notes visible during the countdown to be placed multiple times.
+    // I don't remember what bug I was trying to fix by adding this.
+    // if (conductorInUse.currentStep == 0) nextNoteIndex = 0;
+
     var songStart:Float = PlayState.instance?.startTimestamp ?? 0.0;
     var hitWindowStart:Float = Conductor.instance.songPosition - Constants.HIT_WINDOW_MS;
     var renderWindowStart:Float = Conductor.instance.songPosition + RENDER_DISTANCE_MS;
@@ -365,8 +370,6 @@ class Strumline extends FlxSpriteGroup
         // Hold note is offscreen, kill it.
         holdNote.visible = false;
         holdNote.kill(); // Do not destroy! Recycling is faster.
-
-        // The cover will see this and clean itself up.
       }
       else if (holdNote.hitNote && holdNote.sustainLength <= 0)
       {
@@ -380,10 +383,16 @@ class Strumline extends FlxSpriteGroup
           playStatic(holdNote.noteDirection);
         }
 
-        if (holdNote.cover != null)
+        if (holdNote.cover != null && isPlayer)
         {
           holdNote.cover.playEnd();
         }
+        else if (holdNote.cover != null)
+        {
+          // *lightning* *zap* *crackle*
+          holdNote.cover.visible = false;
+          holdNote.cover.kill();
+        }
 
         holdNote.visible = false;
         holdNote.kill();
@@ -405,6 +414,13 @@ class Strumline extends FlxSpriteGroup
         {
           holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) + yOffset + STRUMLINE_SIZE / 2;
         }
+
+        // Clean up the cover.
+        if (holdNote.cover != null)
+        {
+          holdNote.cover.visible = false;
+          holdNote.cover.kill();
+        }
       }
       else if (Conductor.instance.songPosition > holdNote.strumTime && holdNote.hitNote)
       {
@@ -822,7 +838,7 @@ class Strumline extends FlxSpriteGroup
     {
       // The note sprite pool is full and all note splashes are active.
       // We have to create a new note.
-      result = new SustainTrail(0, 100, noteStyle);
+      result = new SustainTrail(0, 0, noteStyle);
       this.holdNotes.add(result);
     }
 
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 0248e09ee..d219dc2f6 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -404,11 +404,12 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
    *
    * @param variationId Optionally filter by a single variation.
    * @param variationIds Optionally filter by multiple variations.
+   * @param showLocked Include charts which are not unlocked
    * @param showHidden Include charts which are not accessible to the player.
    *
    * @return The list of difficulties.
    */
-  public function listDifficulties(?variationId:String, ?variationIds:Array<String>, showHidden:Bool = false):Array<String>
+  public function listDifficulties(?variationId:String, ?variationIds:Array<String>, showLocked:Bool = false, showHidden:Bool = false):Array<String>
   {
     if (variationIds == null) variationIds = [];
     if (variationId != null) variationIds.push(variationId);
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index db42b0dd3..eb9eb1810 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -223,7 +223,8 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
 
       if (propSprite.frames == null || propSprite.frames.numFrames == 0)
       {
-        trace('    ERROR: Could not build texture for prop.');
+        @:privateAccess
+        trace('    ERROR: Could not build texture for prop. Check the asset path (${Paths.currentLevel ?? 'default'}, ${dataProp.assetPath}).');
         continue;
       }
 
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index 73ba8efa0..bfbda2a02 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -9,12 +9,13 @@ import funkin.save.migrator.SaveDataMigrator;
 import funkin.ui.debug.charting.ChartEditorState.ChartEditorLiveInputStyle;
 import funkin.ui.debug.charting.ChartEditorState.ChartEditorTheme;
 import thx.semver.Version;
+import funkin.util.SerializerUtil;
 
 @:nullSafety
 class Save
 {
   // Version 2.0.2 adds attributes to `optionsChartEditor`, that should return default values if they are null.
-  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.2";
+  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.3";
   public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
 
   // We load this version's saves from a new save path, to maintain SOME level of backwards compatibility.
@@ -391,6 +392,22 @@ class Save
    */
   public function getLevelScore(levelId:String, difficultyId:String = 'normal'):Null<SaveScoreData>
   {
+    if (data.scores?.levels == null)
+    {
+      if (data.scores == null)
+      {
+        data.scores =
+          {
+            songs: [],
+            levels: []
+          };
+      }
+      else
+      {
+        data.scores.levels = [];
+      }
+    }
+
     var level = data.scores.levels.get(levelId);
     if (level == null)
     {
@@ -641,6 +658,9 @@ class Save
   {
     trace("[SAVE] Loading save from slot " + slot + "...");
 
+    // Prevent crashes if the save data is corrupted.
+    SerializerUtil.initSerializer();
+
     FlxG.save.bind('$SAVE_NAME${slot}', SAVE_PATH);
 
     if (FlxG.save.isEmpty())
@@ -650,9 +670,9 @@ class Save
       if (legacySaveData != null)
       {
         trace('[SAVE] Found legacy save data, converting...');
-        var gameSave = SaveDataMigrator.migrate(legacySaveData);
+        var gameSave = SaveDataMigrator.migrateFromLegacy(legacySaveData);
         @:privateAccess
-        FlxG.save.mergeData(gameSave.data);
+        FlxG.save.mergeData(gameSave.data, true);
       }
       else
       {
@@ -664,7 +684,7 @@ class Save
       trace('[SAVE] Loaded save data.');
       @:privateAccess
       var gameSave = SaveDataMigrator.migrate(FlxG.save.data);
-      FlxG.save.mergeData(gameSave.data);
+      FlxG.save.mergeData(gameSave.data, true);
     }
   }
 
@@ -673,7 +693,7 @@ class Save
     trace("[SAVE] Checking for legacy save data...");
     var legacySave:FlxSave = new FlxSave();
     legacySave.bind(SAVE_NAME_LEGACY, SAVE_PATH_LEGACY);
-    if (legacySave?.data == null)
+    if (legacySave.isEmpty())
     {
       trace("[SAVE] No legacy save data found.");
       return null;
diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx
index 00637d52a..3ed59e726 100644
--- a/source/funkin/save/migrator/SaveDataMigrator.hx
+++ b/source/funkin/save/migrator/SaveDataMigrator.hx
@@ -3,6 +3,7 @@ package funkin.save.migrator;
 import funkin.save.Save;
 import funkin.save.migrator.RawSaveData_v1_0_0;
 import thx.semver.Version;
+import funkin.util.StructureUtil;
 import funkin.util.VersionUtil;
 
 @:nullSafety
@@ -26,7 +27,7 @@ class SaveDataMigrator
       if (VersionUtil.validateVersion(version, Save.SAVE_DATA_VERSION_RULE))
       {
         // Simply import the structured data.
-        var save:Save = new Save(inputData);
+        var save:Save = new Save(StructureUtil.deepMerge(Save.getDefault(), inputData));
         return save;
       }
       else
diff --git a/source/funkin/ui/credits/CreditsData.hx b/source/funkin/ui/credits/CreditsData.hx
new file mode 100644
index 000000000..bf7f13ad5
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsData.hx
@@ -0,0 +1,34 @@
+package funkin.ui.credits;
+
+/**
+ * The members of the Funkin' Crew, organized by their roles.
+ */
+typedef CreditsData =
+{
+  var entries:Array<CreditsDataRole>;
+}
+
+/**
+ * The members of a specific role on the Funkin' Crew.
+ */
+typedef CreditsDataRole =
+{
+  @:optional
+  var header:String;
+
+  @:optional
+  @:default([])
+  var body:Array<CreditsDataMember>;
+
+  @:optional
+  @:default(false)
+  var appendBackers:Bool;
+}
+
+/**
+ * A member of a specific person on the Funkin' Crew.
+ */
+typedef CreditsDataMember =
+{
+  var line:String;
+}
diff --git a/source/funkin/ui/credits/CreditsDataHandler.hx b/source/funkin/ui/credits/CreditsDataHandler.hx
new file mode 100644
index 000000000..2240ec50e
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsDataHandler.hx
@@ -0,0 +1,142 @@
+package funkin.ui.credits;
+
+import funkin.data.JsonFile;
+
+using StringTools;
+
+@:nullSafety
+class CreditsDataHandler
+{
+  public static final BACKER_PUBLIC_URL:String = 'https://funkin.me/backers';
+
+  #if HARDCODED_CREDITS
+  static final CREDITS_DATA_PATH:String = "assets/exclude/data/credits.json";
+  #else
+  static final CREDITS_DATA_PATH:String = "assets/data/credits.json";
+  #end
+
+  public static function debugPrint(data:Null<CreditsData>):Void
+  {
+    if (data == null)
+    {
+      trace('CreditsData(NULL)');
+      return;
+    }
+
+    if (data.entries == null || data.entries.length == 0)
+    {
+      trace('CreditsData(EMPTY)');
+      return;
+    }
+
+    var entryCount = data.entries.length;
+    var lineCount = 0;
+    for (entry in data.entries)
+    {
+      lineCount += entry?.body?.length ?? 0;
+    }
+
+    trace('CreditsData($entryCount entries containing $lineCount lines)');
+  }
+
+  /**
+   * If for some reason the full credits won't load,
+   * use this hardcoded data for the original Funkin' Crew.
+   *
+   * @return `CreditsData`
+   */
+  public static inline function getFallback():CreditsData
+  {
+    return {
+      entries: [
+        {
+          header: 'Founders',
+          body: [
+            {line: 'ninjamuffin99'},
+            {line: 'PhantomArcade'},
+            {line: 'KawaiSprite'},
+            {line: 'evilsk8r'},
+          ]
+        }
+      ]
+    };
+  }
+
+  public static function fetchBackerEntries():Array<String>
+  {
+    // TODO: Implement a web request.
+    // We can't just grab the current Kickstarter data and include it in builds,
+    // because we don't want to deadname people who haven't logged into the portal yet.
+    // It can be async and paginated for performance!
+    return [];
+  }
+
+  #if HARDCODED_CREDITS
+  /**
+   * The data for the credits.
+   * Hardcoded into game via a macro at compile time.
+   */
+  public static final CREDITS_DATA:Null<CreditsData> = #if macro null #else CreditsDataMacro.loadCreditsData() #end;
+  #else
+
+  /**
+   * The data for the credits.
+   * Loaded dynamically from the game folder when needed.
+   * Nullable because data may fail to parse.
+   */
+  public static var CREDITS_DATA(get, default):Null<CreditsData> = null;
+
+  static function get_CREDITS_DATA():Null<CreditsData>
+  {
+    if (CREDITS_DATA == null) CREDITS_DATA = parseCreditsData(fetchCreditsData());
+
+    return CREDITS_DATA;
+  }
+
+  static function fetchCreditsData():funkin.data.JsonFile
+  {
+    #if !macro
+    var rawJson:String = openfl.Assets.getText(CREDITS_DATA_PATH).trim();
+
+    return {
+      fileName: CREDITS_DATA_PATH,
+      contents: rawJson
+    };
+    #else
+    return {
+      fileName: CREDITS_DATA_PATH,
+      contents: null
+    };
+    #end
+  }
+
+  static function parseCreditsData(file:JsonFile):Null<CreditsData>
+  {
+    #if !macro
+    if (file.contents == null) return null;
+
+    var parser = new json2object.JsonParser<CreditsData>();
+    parser.ignoreUnknownVariables = false;
+    trace('[CREDITS] Parsing credits data from ${CREDITS_DATA_PATH}');
+    parser.fromJson(file.contents, file.fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, file.fileName);
+      return null;
+    }
+    return parser.value;
+    #else
+    return null;
+    #end
+  }
+
+  static function printErrors(errors:Array<json2object.Error>, id:String = ''):Void
+  {
+    trace('[CREDITS] Failed to parse credits data: ${id}');
+
+    for (error in errors)
+      funkin.data.DataError.printError(error);
+  }
+  #end
+}
diff --git a/source/funkin/ui/credits/CreditsDataMacro.hx b/source/funkin/ui/credits/CreditsDataMacro.hx
new file mode 100644
index 000000000..c97770eef
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsDataMacro.hx
@@ -0,0 +1,67 @@
+package funkin.ui.credits;
+
+#if macro
+import haxe.macro.Context;
+#end
+
+@:access(funkin.ui.credits.CreditsDataHandler)
+class CreditsDataMacro
+{
+  public static macro function loadCreditsData():haxe.macro.Expr.ExprOf<CreditsData>
+  {
+    #if !display
+    trace('Hardcoding credits data...');
+    var json = CreditsDataMacro.fetchJSON();
+
+    if (json == null)
+    {
+      Context.info('[WARN] Could not fetch JSON data for credits.', Context.currentPos());
+      return macro $v{CreditsDataHandler.getFallback()};
+    }
+
+    var creditsData = CreditsDataMacro.parseJSON(json);
+
+    if (creditsData == null)
+    {
+      Context.info('[WARN] Could not parse JSON data for credits.', Context.currentPos());
+      return macro $v{CreditsDataHandler.getFallback()};
+    }
+
+    CreditsDataHandler.debugPrint(creditsData);
+    return macro $v{creditsData};
+    // return macro $v{null};
+    #else
+    // `#if display` is used for code completion. In this case we return
+    // a minimal value to keep code completion fast.
+    return macro $v{CreditsDataHandler.getFallback()};
+    #end
+  }
+
+  #if macro
+  static function fetchJSON():Null<String>
+  {
+    return sys.io.File.getContent(CreditsDataHandler.CREDITS_DATA_PATH);
+  }
+
+  /**
+   * Parse the JSON data for the credits.
+   *
+   * @param json The string data to parse.
+   * @return The parsed data.
+   */
+  static function parseJSON(json:String):Null<CreditsData>
+  {
+    try
+    {
+      // TODO: Use something with better validation but that still works at macro time.
+      return haxe.Json.parse(json);
+    }
+    catch (e)
+    {
+      trace('[ERROR] Failed to parse JSON data for credits.');
+      trace(e);
+      return null;
+    }
+  }
+  #end
+}
diff --git a/source/funkin/ui/credits/CreditsState.hx b/source/funkin/ui/credits/CreditsState.hx
new file mode 100644
index 000000000..d43e25114
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsState.hx
@@ -0,0 +1,213 @@
+package funkin.ui.credits;
+
+import flixel.text.FlxText;
+import flixel.util.FlxColor;
+import funkin.audio.FunkinSound;
+import flixel.FlxSprite;
+import flixel.group.FlxSpriteGroup;
+
+/**
+ * The state used to display the credits scroll.
+ * AAA studios often fail to credit properly, and we're better than them!
+ */
+class CreditsState extends MusicBeatState
+{
+  /**
+   * The height the credits should start at.
+   * Make this an instanced variable so it gets set by the constructor.
+   */
+  final STARTING_HEIGHT = FlxG.height;
+
+  /**
+   * The padding on each side of the screen.
+   */
+  static final SCREEN_PAD = 24;
+
+  /**
+   * The width of the screen the credits should maximally fill up.
+   * Make this an instanced variable so it gets set by the constructor.
+   */
+  final FULL_WIDTH = FlxG.width - (SCREEN_PAD * 2);
+
+  /**
+   * The font to use to display the text.
+   * To use a font from the `assets` folder, use `Paths.font(...)`.
+   * Choose something that will render Unicode properly.
+   */
+  static final CREDITS_FONT = 'Arial';
+
+  /**
+   * The size of the font.
+   */
+  static final CREDITS_FONT_SIZE = 48;
+
+  static final CREDITS_HEADER_FONT_SIZE = 72;
+
+  /**
+   * The color of the text itself.
+   */
+  static final CREDITS_FONT_COLOR = FlxColor.WHITE;
+
+  /**
+   * The color of the text's outline.
+   */
+  static final CREDITS_FONT_STROKE_COLOR = FlxColor.BLACK;
+
+  /**
+   * The speed the credits scroll at, in pixels per second.
+   */
+  static final CREDITS_SCROLL_BASE_SPEED = 25.0;
+
+  /**
+   * The speed the credits scroll at while the button is held, in pixels per second.
+   */
+  static final CREDITS_SCROLL_FAST_SPEED = CREDITS_SCROLL_BASE_SPEED * 4.0;
+
+  /**
+   * The actual sprites and text used to display the credits.
+   */
+  var creditsGroup:FlxSpriteGroup;
+
+  var scrollPaused:Bool = false;
+
+  public function new()
+  {
+    super();
+  }
+
+  public override function create():Void
+  {
+    super.create();
+
+    // Background
+    var bg = new FlxSprite(Paths.image('menuDesat'));
+    bg.scrollFactor.x = 0;
+    bg.scrollFactor.y = 0;
+    bg.setGraphicSize(Std.int(FlxG.width));
+    bg.updateHitbox();
+    bg.x = 0;
+    bg.y = 0;
+    bg.visible = true;
+    bg.color = 0xFFB57EDC; // Lavender
+    add(bg);
+
+    // TODO: Once we need to display Kickstarter backers,
+    // make this use a recycled pool so we don't kill peformance.
+    creditsGroup = new FlxSpriteGroup();
+    creditsGroup.x = SCREEN_PAD;
+    creditsGroup.y = STARTING_HEIGHT;
+
+    buildCreditsGroup();
+
+    add(creditsGroup);
+
+    // Music
+    FunkinSound.playMusic('freeplayRandom',
+      {
+        startingVolume: 0.0,
+        overrideExisting: true,
+        restartTrack: true,
+        loop: true
+      });
+    FlxG.sound.music.fadeIn(2, 0, 0.8);
+  }
+
+  function buildCreditsGroup():Void
+  {
+    var y = 0;
+
+    for (entry in CreditsDataHandler.CREDITS_DATA.entries)
+    {
+      if (entry.header != null)
+      {
+        creditsGroup.add(buildCreditsLine(entry.header, y, true, CreditsSide.Center));
+        y += CREDITS_HEADER_FONT_SIZE;
+      }
+
+      for (line in entry?.body ?? [])
+      {
+        creditsGroup.add(buildCreditsLine(line.line, y, false, CreditsSide.Center));
+        y += CREDITS_FONT_SIZE;
+      }
+
+      if (entry.appendBackers)
+      {
+        var backers = CreditsDataHandler.fetchBackerEntries();
+        for (backer in backers)
+        {
+          creditsGroup.add(buildCreditsLine(backer, y, false, CreditsSide.Center));
+          y += CREDITS_FONT_SIZE;
+        }
+      }
+
+      // Padding between each role.
+      y += CREDITS_FONT_SIZE * 2;
+    }
+  }
+
+  function buildCreditsLine(text:String, yPos:Float, header:Bool, side:CreditsSide = CreditsSide.Center):FlxText
+  {
+    // CreditsSide.Center: Full screen width
+    // CreditsSide.Left: Left half of screen
+    // CreditsSide.Right: Right half of screen
+    var xPos = (side == CreditsSide.Right) ? (FULL_WIDTH / 2) : 0;
+    var width = (side == CreditsSide.Center) ? FULL_WIDTH : (FULL_WIDTH / 2);
+    var size = header ? CREDITS_HEADER_FONT_SIZE : CREDITS_FONT_SIZE;
+
+    var creditsLine:FlxText = new FlxText(xPos, yPos, width, text);
+    creditsLine.setFormat(CREDITS_FONT, size, CREDITS_FONT_COLOR, FlxTextAlign.CENTER, FlxTextBorderStyle.OUTLINE, CREDITS_FONT_STROKE_COLOR, true);
+
+    return creditsLine;
+  }
+
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    if (!scrollPaused)
+    {
+      // TODO: Replace with whatever the special note button is.
+      if (controls.ACCEPT || FlxG.keys.pressed.SPACE)
+      {
+        // Move the whole group.
+        creditsGroup.y -= CREDITS_SCROLL_FAST_SPEED * elapsed;
+      }
+      else
+      {
+        // Move the whole group.
+        creditsGroup.y -= CREDITS_SCROLL_BASE_SPEED * elapsed;
+      }
+    }
+
+    if (controls.BACK || hasEnded())
+    {
+      exit();
+    }
+    else if (controls.PAUSE)
+    {
+      scrollPaused = !scrollPaused;
+    }
+  }
+
+  function hasEnded():Bool
+  {
+    return creditsGroup.y < -creditsGroup.height;
+  }
+
+  function exit():Void
+  {
+    FlxG.switchState(new funkin.ui.mainmenu.MainMenuState());
+  }
+
+  public override function destroy():Void
+  {
+    super.destroy();
+  }
+}
+
+enum CreditsSide
+{
+  Left;
+  Center;
+  Right;
+}
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 4bb3cd839..4e572a26f 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -6,7 +6,6 @@ import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxCamera;
 import flixel.FlxSprite;
 import flixel.FlxSubState;
-import flixel.graphics.FlxGraphic;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.group.FlxSpriteGroup;
 import flixel.input.gamepad.FlxGamepadInputID;
@@ -15,6 +14,7 @@ import flixel.input.mouse.FlxMouseEvent;
 import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import flixel.math.FlxRect;
+import flixel.sound.FlxSound;
 import flixel.system.debug.log.LogStyle;
 import flixel.system.FlxAssets.FlxSoundAsset;
 import flixel.text.FlxText;
@@ -26,8 +26,6 @@ import flixel.util.FlxSort;
 import flixel.util.FlxTimer;
 import funkin.audio.FunkinSound;
 import funkin.audio.visualize.PolygonSpectogram;
-import funkin.audio.visualize.PolygonVisGroup;
-import funkin.audio.VoicesGroup;
 import funkin.audio.VoicesGroup;
 import funkin.audio.waveform.WaveformSprite;
 import funkin.data.notestyle.NoteStyleRegistry;
@@ -53,6 +51,7 @@ import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.components.HealthIcon;
 import funkin.play.notes.NoteSprite;
 import funkin.play.PlayState;
+import funkin.play.PlayStatePlaylist;
 import funkin.play.song.Song;
 import funkin.save.Save;
 import funkin.ui.debug.charting.commands.AddEventsCommand;
@@ -91,6 +90,7 @@ import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
 import funkin.ui.haxeui.components.CharacterPlayer;
 import funkin.ui.haxeui.HaxeUIState;
 import funkin.ui.mainmenu.MainMenuState;
+import funkin.ui.transition.LoadingState;
 import funkin.util.Constants;
 import funkin.util.FileUtil;
 import funkin.util.logging.CrashHandler;
@@ -5655,30 +5655,31 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     }
     catch (e)
     {
-      this.error("Could Not Playtest", 'Got an error trying to playtest the song.\n${e}');
+      this.error('Could Not Playtest', 'Got an error trying to playtest the song.\n${e}');
       return;
     }
 
-    // TODO: Rework asset system so we can remove this.
+    // TODO: Rework asset system so we can remove this jank.
     switch (currentSongStage)
     {
       case 'mainStage':
-        Paths.setCurrentLevel('week1');
+        PlayStatePlaylist.campaignId = 'week1';
       case 'spookyMansion':
-        Paths.setCurrentLevel('week2');
+        PlayStatePlaylist.campaignId = 'week2';
       case 'phillyTrain':
-        Paths.setCurrentLevel('week3');
+        PlayStatePlaylist.campaignId = 'week3';
       case 'limoRide':
-        Paths.setCurrentLevel('week4');
+        PlayStatePlaylist.campaignId = 'week4';
       case 'mallXmas' | 'mallEvil':
-        Paths.setCurrentLevel('week5');
+        PlayStatePlaylist.campaignId = 'week5';
       case 'school' | 'schoolEvil':
-        Paths.setCurrentLevel('week6');
+        PlayStatePlaylist.campaignId = 'week6';
       case 'tankmanBattlefield':
-        Paths.setCurrentLevel('week7');
+        PlayStatePlaylist.campaignId = 'week7';
       case 'phillyStreets' | 'phillyBlazin' | 'phillyBlazin2':
-        Paths.setCurrentLevel('weekend1');
+        PlayStatePlaylist.campaignId = 'weekend1';
     }
+    Paths.setCurrentLevel(PlayStatePlaylist.campaignId);
 
     subStateClosed.add(reviveUICamera);
     subStateClosed.add(resetConductorAfterTest);
@@ -5686,7 +5687,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     FlxTransitionableState.skipNextTransIn = false;
     FlxTransitionableState.skipNextTransOut = false;
 
-    var targetState = new PlayState(
+    var targetStateParams =
       {
         targetSong: targetSong,
         targetDifficulty: selectedDifficulty,
@@ -5697,14 +5698,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         startTimestamp: startTimestamp,
         playbackRate: playbackRate,
         overrideMusic: true,
-      });
+      };
 
     // Override music.
     if (audioInstTrack != null)
     {
       FlxG.sound.music = audioInstTrack;
     }
-    targetState.vocals = audioVocalTrackGroup;
 
     // Kill and replace the UI camera so it doesn't get destroyed during the state transition.
     uiCamera.kill();
@@ -5714,7 +5714,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     this.persistentUpdate = false;
     this.persistentDraw = false;
     stopWelcomeMusic();
-    openSubState(targetState);
+
+    LoadingState.loadPlayState(targetStateParams, false, true, function(targetState) {
+      targetState.vocals = audioVocalTrackGroup;
+    });
   }
 
   /**
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx
index cd403c6f8..98f5a47aa 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx
@@ -117,12 +117,6 @@ class ChartEditorNoteSprite extends FlxSprite
     {
       noteFrameCollection.pushFrame(frame);
     }
-    var frameCollectionNormal2:FlxAtlasFrames = Paths.getSparrowAtlas('NoteHoldNormal');
-
-    for (frame in frameCollectionNormal2.frames)
-    {
-      noteFrameCollection.pushFrame(frame);
-    }
 
     // Pixel notes
     var graphicPixel = FlxG.bitmap.add(Paths.image('weeb/pixelUI/arrows-pixels', 'week6'), false, null);
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
index 557875596..0308cd871 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
@@ -73,7 +73,7 @@ class ChartEditorImportExportHandler
         state.loadInstFromAsset(Paths.inst(songId, '-$variation'), variation);
       }
 
-      for (difficultyId in song.listDifficulties(variation))
+      for (difficultyId in song.listDifficulties(variation, true, true))
       {
         var diff:Null<SongDifficulty> = song.getDifficulty(difficultyId, variation);
         if (diff == null) continue;
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
index 8c7b1a8c1..f82bc3c1f 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
@@ -308,16 +308,6 @@ class ChartEditorToolboxHandler
       state.playtestBotPlayMode = checkboxBotPlay.selected;
     };
 
-    var checkboxDebugger:Null<CheckBox> = toolbox.findComponent('playtestDebuggerCheckbox', CheckBox);
-
-    if (checkboxDebugger == null) throw 'ChartEditorToolboxHandler.buildToolboxPlaytestPropertiesLayout() - Could not find playtestDebuggerCheckbox component.';
-
-    state.enabledDebuggerPopup = checkboxDebugger.selected;
-
-    checkboxDebugger.onClick = _ -> {
-      state.enabledDebuggerPopup = checkboxDebugger.selected;
-    };
-
     var checkboxSongScripts:Null<CheckBox> = toolbox.findComponent('playtestSongScriptsCheckbox', CheckBox);
 
     if (checkboxSongScripts == null)
diff --git a/source/funkin/ui/debug/latency/LatencyState.hx b/source/funkin/ui/debug/latency/LatencyState.hx
index 7b2eabb1c..620f0edd7 100644
--- a/source/funkin/ui/debug/latency/LatencyState.hx
+++ b/source/funkin/ui/debug/latency/LatencyState.hx
@@ -171,10 +171,7 @@ class LatencyState extends MusicBeatSubState
       trace(FlxG.sound.music._channel.position);
      */
 
-    #if FLX_DEBUG
-    funnyStatsGraph.update(FlxG.sound.music.time % 500);
-    realStats.update(swagSong.getTimeWithDiff() % 500);
-    #end
+    // localConductor.update(swagSong.time, false);
 
     if (FlxG.keys.justPressed.S)
     {
diff --git a/source/funkin/ui/freeplay/Album.hx b/source/funkin/ui/freeplay/Album.hx
index 7291c7357..3060d3eb8 100644
--- a/source/funkin/ui/freeplay/Album.hx
+++ b/source/funkin/ui/freeplay/Album.hx
@@ -1,6 +1,7 @@
 package funkin.ui.freeplay;
 
 import funkin.data.freeplay.AlbumData;
+import funkin.data.animation.AnimationData;
 import funkin.data.freeplay.AlbumRegistry;
 import funkin.data.IRegistryEntry;
 import flixel.graphics.FlxGraphic;
@@ -75,6 +76,16 @@ class Album implements IRegistryEntry<AlbumData>
     return _data.albumTitleAsset;
   }
 
+  public function hasAlbumTitleAnimations()
+  {
+    return _data.albumTitleAnimations.length > 0;
+  }
+
+  public function getAlbumTitleAnimations():Array<AnimationData>
+  {
+    return _data.albumTitleAnimations;
+  }
+
   public function toString():String
   {
     return 'Album($id)';
diff --git a/source/funkin/ui/freeplay/AlbumRoll.hx b/source/funkin/ui/freeplay/AlbumRoll.hx
index a1e63c9a1..6b963a242 100644
--- a/source/funkin/ui/freeplay/AlbumRoll.hx
+++ b/source/funkin/ui/freeplay/AlbumRoll.hx
@@ -1,5 +1,6 @@
 package funkin.ui.freeplay;
 
+import funkin.graphics.adobeanimate.FlxAtlasSprite;
 import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup;
 import flixel.util.FlxSort;
@@ -7,6 +8,7 @@ import flixel.tweens.FlxTween;
 import flixel.util.FlxTimer;
 import flixel.tweens.FlxEase;
 import funkin.data.freeplay.AlbumRegistry;
+import funkin.util.assets.FlxAnimationUtil;
 import funkin.graphics.FunkinSprite;
 import funkin.util.SortUtil;
 import openfl.utils.Assets;
@@ -21,9 +23,9 @@ class AlbumRoll extends FlxSpriteGroup
    * The ID of the album to display.
    * Modify this value to automatically update the album art and title.
    */
-  public var albumId(default, set):String;
+  public var albumId(default, set):Null<String>;
 
-  function set_albumId(value:String):String
+  function set_albumId(value:Null<String>):Null<String>
   {
     if (this.albumId != value)
     {
@@ -34,30 +36,47 @@ class AlbumRoll extends FlxSpriteGroup
     return value;
   }
 
-  var albumArt:FunkinSprite;
-  var albumTitle:FunkinSprite;
-  var difficultyStars:DifficultyStars;
+  var newAlbumArt:FlxAtlasSprite;
 
+  // var difficultyStars:DifficultyStars;
   var _exitMovers:Null<FreeplayState.ExitMoverData>;
 
   var albumData:Album;
 
+  final animNames:Map<String, String> = [
+    "volume1-active" => "ENTRANCE",
+    "volume2-active" => "ENTRANCE VOL2",
+    "volume3-active" => "ENTRANCE VOL3",
+    "volume1-trans" => "VOL1 TRANS",
+    "volume2-trans" => "VOL2 TRANS",
+    "volume3-trans" => "VOL3 TRANS",
+    "volume1-idle" => "VOL1 STILL",
+    "volume2-idle" => "VOL2 STILL",
+    "volume3-idle" => "VOL3 STILL",
+  ];
+
   public function new()
   {
     super();
 
-    albumTitle = new FunkinSprite(947, 491);
-    albumTitle.visible = true;
-    albumTitle.zIndex = 200;
-    add(albumTitle);
+    newAlbumArt = new FlxAtlasSprite(0, 0, Paths.animateAtlas("freeplay/albumRoll/freeplayAlbum"));
+    newAlbumArt.visible = false;
+    newAlbumArt.onAnimationFinish.add(onAlbumFinish);
 
-    difficultyStars = new DifficultyStars(140, 39);
+    add(newAlbumArt);
 
-    difficultyStars.stars.visible = true;
-    albumTitle.visible = false;
-    // albumArtist.visible = false;
+    // difficultyStars = new DifficultyStars(140, 39);
+    // difficultyStars.stars.visible = false;
+    // add(difficultyStars);
+  }
 
-    // var albumArtist:FlxSprite = new FlxSprite(1010, 607).loadGraphic(Paths.image('freeplay/albumArtist-kawaisprite'));
+  function onAlbumFinish(animName:String):Void
+  {
+    // Play the idle animation for the current album.
+    newAlbumArt.playAnimation(animNames.get('$albumId-idle'), false, false, true);
+
+    // End on the last frame and don't continue until playAnimation is called again.
+    // newAlbumArt.anim.pause();
   }
 
   /**
@@ -65,6 +84,12 @@ class AlbumRoll extends FlxSpriteGroup
    */
   function updateAlbum():Void
   {
+    if (albumId == null)
+    {
+      // difficultyStars.stars.visible = false;
+      return;
+    }
+
     albumData = AlbumRegistry.instance.fetchEntry(albumId);
 
     if (albumData == null)
@@ -74,33 +99,8 @@ class AlbumRoll extends FlxSpriteGroup
       return;
     };
 
-    if (albumArt != null)
-    {
-      FlxTween.cancelTweensOf(albumArt);
-      albumArt.visible = false;
-      albumArt.destroy();
-      remove(albumArt);
-    }
-
-    // Paths.animateAtlas('freeplay/albumRoll'),
-    albumArt = FunkinSprite.create(1500, 360, albumData.getAlbumArtAssetKey());
-    albumArt.setGraphicSize(262, 262); // Magic number for size IG
-    albumArt.zIndex = 100;
-
-    // playIntro();
-    add(albumArt);
-
     applyExitMovers();
 
-    if (Assets.exists(Paths.image(albumData.getAlbumTitleAssetKey())))
-    {
-      albumTitle.loadGraphic(Paths.image(albumData.getAlbumTitleAssetKey()));
-    }
-    else
-    {
-      albumTitle.visible = false;
-    }
-
     refresh();
   }
 
@@ -126,67 +126,46 @@ class AlbumRoll extends FlxSpriteGroup
 
     if (exitMovers == null) return;
 
-    exitMovers.set([albumArt],
+    exitMovers.set([newAlbumArt],
       {
         x: FlxG.width,
         speed: 0.4,
         wait: 0
       });
-    exitMovers.set([albumTitle],
-      {
-        x: FlxG.width,
-        speed: 0.2,
-        wait: 0.1
-      });
-
-    /*
-      exitMovers.set([albumArtist],
-        {
-          x: FlxG.width * 1.1,
-          speed: 0.2,
-          wait: 0.2
-        });
-     */
-    exitMovers.set([difficultyStars],
-      {
-        x: FlxG.width * 1.2,
-        speed: 0.2,
-        wait: 0.3
-      });
   }
 
+  var titleTimer:Null<FlxTimer> = null;
+
   /**
    * Play the intro animation on the album art.
    */
   public function playIntro():Void
   {
-    albumArt.visible = true;
-    FlxTween.tween(albumArt, {x: 950, y: 320, angle: -340}, 0.5, {ease: FlxEase.elasticOut});
+    newAlbumArt.visible = true;
+    newAlbumArt.playAnimation(animNames.get('$albumId-active'), false, false, false);
 
-    albumTitle.visible = false;
+    // difficultyStars.stars.visible = false;
     new FlxTimer().start(0.75, function(_) {
-      showTitle();
+      // showTitle();
+      // showStars();
     });
   }
 
-  public function setDifficultyStars(?difficulty:Int):Void
+  public function skipIntro():Void
   {
-    if (difficulty == null) return;
-
-    difficultyStars.difficulty = difficulty;
+    newAlbumArt.playAnimation(animNames.get('$albumId-trans'), false, false, false);
   }
 
-  public function showTitle():Void
-  {
-    albumTitle.visible = true;
-  }
-
-  /**
-   * Make the album stars visible.
-   */
-  public function showStars():Void
-  {
-    // albumArtist.visible = false;
-    difficultyStars.stars.visible = false;
-  }
+  // public function setDifficultyStars(?difficulty:Int):Void
+  // {
+  //   if (difficulty == null) return;
+  //   difficultyStars.difficulty = difficulty;
+  // }
+  // /**
+  //  * Make the album stars visible.
+  //  */
+  // public function showStars():Void
+  // {
+  //   difficultyStars.stars.visible = false; // true;
+  // }
 }
diff --git a/source/funkin/ui/freeplay/DJBoyfriend.hx b/source/funkin/ui/freeplay/DJBoyfriend.hx
index 33f264301..5f1144fab 100644
--- a/source/funkin/ui/freeplay/DJBoyfriend.hx
+++ b/source/funkin/ui/freeplay/DJBoyfriend.hx
@@ -27,8 +27,8 @@ class DJBoyfriend extends FlxAtlasSprite
 
   var gotSpooked:Bool = false;
 
-  static final SPOOK_PERIOD:Float = 10.0;
-  static final TV_PERIOD:Float = 10.0;
+  static final SPOOK_PERIOD:Float = 120.0;
+  static final TV_PERIOD:Float = 180.0;
 
   // Time since dad last SPOOKED you.
   var timeSinceSpook:Float = 0;
@@ -43,7 +43,14 @@ class DJBoyfriend extends FlxAtlasSprite
       switch (name)
       {
         case "Boyfriend DJ watchin tv OG":
-          if (number == 85) runTvLogic();
+          if (number == 80)
+          {
+            FunkinSound.playOnce(Paths.sound('remote_click'));
+          }
+          if (number == 85)
+          {
+            runTvLogic();
+          }
         default:
       }
     };
@@ -219,19 +226,17 @@ class DJBoyfriend extends FlxAtlasSprite
     if (cartoonSnd == null)
     {
       // tv is OFF, but getting turned on
-      // Eric got FUCKING TROLLED there is no `tv_on` or `channel_switch` sound!
-      // FunkinSound.playOnce(Paths.sound('tv_on'), 1.0, function() {
-      // });
-      loadCartoon();
+      FunkinSound.playOnce(Paths.sound('tv_on'), 1.0, function() {
+        loadCartoon();
+      });
     }
     else
     {
       // plays it smidge after the click
-      // new FlxTimer().start(0.1, function(_) {
-      //   // FunkinSound.playOnce(Paths.sound('channel_switch'));
-      // });
-      cartoonSnd.destroy();
-      loadCartoon();
+      FunkinSound.playOnce(Paths.sound('channel_switch'), 1.0, function() {
+        cartoonSnd.destroy();
+        loadCartoon();
+      });
     }
 
     // loadCartoon();
diff --git a/source/funkin/ui/freeplay/DifficultyStars.hx b/source/funkin/ui/freeplay/DifficultyStars.hx
deleted file mode 100644
index 51526bcbe..000000000
--- a/source/funkin/ui/freeplay/DifficultyStars.hx
+++ /dev/null
@@ -1,106 +0,0 @@
-package funkin.ui.freeplay;
-
-import flixel.group.FlxSpriteGroup;
-import funkin.graphics.adobeanimate.FlxAtlasSprite;
-import funkin.graphics.shaders.HSVShader;
-
-class DifficultyStars extends FlxSpriteGroup
-{
-  /**
-   * Internal handler var for difficulty... ranges from 0... to 15
-   * 0 is 1 star... 15 is 0 stars!
-   */
-  var curDifficulty(default, set):Int = 0;
-
-  /**
-   * Range between 0 and 15
-   */
-  public var difficulty(default, set):Int = 1;
-
-  public var stars:FlxAtlasSprite;
-
-  var flames:FreeplayFlames;
-
-  var hsvShader:HSVShader;
-
-  public function new(x:Float, y:Float)
-  {
-    super(x, y);
-
-    hsvShader = new HSVShader();
-
-    flames = new FreeplayFlames(0, 0);
-    add(flames);
-
-    stars = new FlxAtlasSprite(0, 0, Paths.animateAtlas("freeplay/freeplayStars"));
-    stars.anim.play("diff stars");
-    add(stars);
-
-    stars.shader = hsvShader;
-
-    for (memb in flames.members)
-      memb.shader = hsvShader;
-  }
-
-  override function update(elapsed:Float):Void
-  {
-    super.update(elapsed);
-
-    // "loops" the current animation
-    // for clarity, the animation file looks like
-    // frame : stars
-    // 0-99: 1 star
-    // 100-199: 2 stars
-    // ......
-    // 1300-1499: 15 stars
-    // 1500 : 0 stars
-    if (curDifficulty < 15 && stars.anim.curFrame >= (curDifficulty + 1) * 100)
-    {
-      stars.anim.play("diff stars", true, false, curDifficulty * 100);
-    }
-  }
-
-  function set_difficulty(value:Int):Int
-  {
-    difficulty = value;
-
-    if (difficulty <= 0)
-    {
-      difficulty = 0;
-      curDifficulty = 15;
-    }
-    else if (difficulty <= 15)
-    {
-      difficulty = value;
-      curDifficulty = difficulty - 1;
-    }
-    else
-    {
-      difficulty = 15;
-      curDifficulty = difficulty - 1;
-    }
-
-    if (difficulty > 10) flames.flameCount = difficulty - 10;
-    else
-      flames.flameCount = 0;
-
-    return difficulty;
-  }
-
-  function set_curDifficulty(value:Int):Int
-  {
-    curDifficulty = value;
-    if (curDifficulty == 15)
-    {
-      stars.anim.play("diff stars", true, false, 1500);
-      stars.anim.pause();
-    }
-    else
-    {
-      stars.anim.curFrame = Std.int(curDifficulty * 100);
-      stars.anim.play("diff stars", true, false, curDifficulty * 100);
-    }
-
-    return curDifficulty;
-  }
-}
diff --git a/source/funkin/ui/freeplay/FreeplayScore.hx b/source/funkin/ui/freeplay/FreeplayScore.hx
index 413b182e0..da4c9f5d4 100644
--- a/source/funkin/ui/freeplay/FreeplayScore.hx
+++ b/source/funkin/ui/freeplay/FreeplayScore.hx
@@ -42,11 +42,11 @@ class FreeplayScore extends FlxTypedSpriteGroup<ScoreNum>
     return val;
   }
 
-  public function new(x:Float, y:Float, scoreShit:Int = 100)
+  public function new(x:Float, y:Float, digitCount:Int, scoreShit:Int = 100)
   {
     super(x, y);
 
-    for (i in 0...7)
+    for (i in 0...digitCount)
     {
       add(new ScoreNum(x + (45 * i), y, 0));
     }
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 6cb0d1d9a..3ac441212 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -133,8 +133,8 @@ class FreeplayState extends MusicBeatSubState
 
   var stickerSubState:StickerSubState;
 
-  static var rememberedDifficulty:Null<String> = Constants.DEFAULT_DIFFICULTY;
-  static var rememberedSongId:Null<String> = null;
+  public static var rememberedDifficulty:Null<String> = Constants.DEFAULT_DIFFICULTY;
+  public static var rememberedSongId:Null<String> = 'tutorial';
 
   public function new(?params:FreeplayStateParams, ?stickers:StickerSubState)
   {
@@ -145,7 +145,7 @@ class FreeplayState extends MusicBeatSubState
       stickerSubState = stickers;
     }
 
-    super();
+    super(FlxColor.TRANSPARENT);
   }
 
   override function create():Void
@@ -195,7 +195,7 @@ class FreeplayState extends MusicBeatSubState
         var song:Song = SongRegistry.instance.fetchEntry(songId);
 
         // Only display songs which actually have available charts for the current character.
-        var availableDifficultiesForSong:Array<String> = song.listDifficulties(displayedVariations);
+        var availableDifficultiesForSong:Array<String> = song.listDifficulties(displayedVariations, false);
         if (availableDifficultiesForSong.length == 0) continue;
 
         songs.push(new FreeplaySongData(levelId, songId, song, displayedVariations));
@@ -380,7 +380,7 @@ class FreeplayState extends MusicBeatSubState
     }
 
     albumRoll = new AlbumRoll();
-    albumRoll.albumId = 'volume1';
+    albumRoll.albumId = null;
     add(albumRoll);
 
     albumRoll.applyExitMovers(exitMovers);
@@ -425,7 +425,7 @@ class FreeplayState extends MusicBeatSubState
       tmr.time = FlxG.random.float(20, 60);
     }, 0);
 
-    fp = new FreeplayScore(460, 60, 100);
+    fp = new FreeplayScore(460, 60, 7, 100);
     fp.visible = false;
     add(fp);
 
@@ -470,11 +470,7 @@ class FreeplayState extends MusicBeatSubState
       albumRoll.playIntro();
 
       new FlxTimer().start(0.75, function(_) {
-        albumRoll.showTitle();
-      });
-
-      new FlxTimer().start(35 / 24, function(_) {
-        albumRoll.showStars();
+        // albumRoll.showTitle();
       });
 
       FlxTween.tween(grpDifficulties, {x: 90}, 0.6, {ease: FlxEase.quartOut});
@@ -536,21 +532,18 @@ class FreeplayState extends MusicBeatSubState
     });
   }
 
+  var currentFilter:SongFilter = null;
+  var currentFilteredSongs:Array<FreeplaySongData> = [];
+
   /**
    * Given the current filter, rebuild the current song list.
    *
    * @param filterStuff A filter to apply to the song list (regex, startswith, all, favorite)
    * @param force
+   * @param onlyIfChanged Only apply the filter if the song list has changed
    */
-  public function generateSongList(?filterStuff:SongFilter, force:Bool = false):Void
+  public function generateSongList(filterStuff:Null<SongFilter>, force:Bool = false, onlyIfChanged:Bool = true):Void
   {
-    curSelected = 1;
-
-    for (cap in grpCapsules.members)
-    {
-      cap.kill();
-    }
-
     var tempSongs:Array<FreeplaySongData> = songs;
 
     if (filterStuff != null)
@@ -582,6 +575,35 @@ class FreeplayState extends MusicBeatSubState
       }
     }
 
+    // Filter further by current selected difficulty.
+    if (currentDifficulty != null)
+    {
+      tempSongs = tempSongs.filter(song -> {
+        if (song == null) return true; // Random
+        return song.songDifficulties.contains(currentDifficulty);
+      });
+    }
+
+    if (onlyIfChanged)
+    {
+      // == performs equality by reference
+      if (tempSongs.isEqualUnordered(currentFilteredSongs)) return;
+    }
+
+    // Only now do we know that the filter is actually changing.
+
+    rememberedSongId = grpCapsules.members[curSelected]?.songData?.songId ?? rememberedSongId;
+
+    for (cap in grpCapsules.members)
+    {
+      cap.kill();
+    }
+
+    currentFilter = filterStuff;
+
+    currentFilteredSongs = tempSongs;
+    curSelected = 0;
+
     var hsvShader:HSVShader = new HSVShader();
 
     var randomCapsule:SongMenuItem = grpCapsules.recycle(SongMenuItem);
@@ -616,14 +638,7 @@ class FreeplayState extends MusicBeatSubState
       funnyMenu.favIcon.visible = tempSongs[i].isFav;
       funnyMenu.hsvShader = hsvShader;
 
-      if (i < 8)
-      {
-        funnyMenu.initJumpIn(Math.min(i, 4), force);
-      }
-      else
-      {
-        funnyMenu.forcePosition();
-      }
+      funnyMenu.forcePosition();
 
       grpCapsules.add(funnyMenu);
     }
@@ -658,11 +673,12 @@ class FreeplayState extends MusicBeatSubState
 
     if (FlxG.keys.justPressed.F)
     {
-      if (songs[curSelected] != null)
+      var targetSong = grpCapsules.members[curSelected]?.songData;
+      if (targetSong != null)
       {
         var realShit:Int = curSelected;
-        songs[curSelected].isFav = !songs[curSelected].isFav;
-        if (songs[curSelected].isFav)
+        targetSong.isFav = !targetSong.isFav;
+        if (targetSong.isFav)
         {
           FlxTween.tween(grpCapsules.members[realShit], {angle: 360}, 0.4,
             {
@@ -854,11 +870,13 @@ class FreeplayState extends MusicBeatSubState
     {
       dj.resetAFKTimer();
       changeDiff(-1);
+      generateSongList(currentFilter, true);
     }
     if (controls.UI_RIGHT_P && !FlxG.keys.pressed.CONTROL)
     {
       dj.resetAFKTimer();
       changeDiff(1);
+      generateSongList(currentFilter, true);
     }
 
     if (controls.BACK && !typing.hasFocus)
@@ -877,6 +895,8 @@ class FreeplayState extends MusicBeatSubState
 
         for (spr in grpSpr)
         {
+          if (spr == null) continue;
+
           var funnyMoveShit:MoveData = moveData;
 
           if (moveData.x == null) funnyMoveShit.x = spr.x;
@@ -899,7 +919,7 @@ class FreeplayState extends MusicBeatSubState
 
       if (Type.getClass(FlxG.state) == MainMenuState)
       {
-        FlxG.state.persistentUpdate = true;
+        FlxG.state.persistentUpdate = false;
         FlxG.state.persistentDraw = true;
       }
 
@@ -908,6 +928,11 @@ class FreeplayState extends MusicBeatSubState
         FlxTransitionableState.skipNextTransOut = true;
         if (Type.getClass(FlxG.state) == MainMenuState)
         {
+          FunkinSound.playMusic('freakyMenu',
+            {
+              overrideExisting: true,
+              restartTrack: false
+            });
           close();
         }
         else
@@ -926,7 +951,7 @@ class FreeplayState extends MusicBeatSubState
   public override function destroy():Void
   {
     super.destroy();
-    var daSong:Null<FreeplaySongData> = songs[curSelected];
+    var daSong:Null<FreeplaySongData> = currentFilteredSongs[curSelected];
     if (daSong != null)
     {
       clearDaCache(daSong.songName);
@@ -948,10 +973,10 @@ class FreeplayState extends MusicBeatSubState
 
     currentDifficulty = diffIdsCurrent[currentDifficultyIndex];
 
-    var daSong:Null<FreeplaySongData> = songs[curSelected];
+    var daSong:Null<FreeplaySongData> = grpCapsules.members[curSelected].songData;
     if (daSong != null)
     {
-      var songScore:SaveScoreData = Save.instance.getSongScore(songs[curSelected].songId, currentDifficulty);
+      var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, currentDifficulty);
       intendedScore = songScore?.score ?? 0;
       intendedCompletion = songScore?.accuracy ?? 0.0;
       rememberedDifficulty = currentDifficulty;
@@ -1011,15 +1036,12 @@ class FreeplayState extends MusicBeatSubState
       }
     }
 
-    // Set the difficulty star count on the right.
-    albumRoll.setDifficultyStars(daSong?.songRating);
-
     // Set the album graphic and play the animation if relevant.
-    var newAlbumId:String = daSong?.albumId ?? Constants.DEFAULT_ALBUM_ID;
+    var newAlbumId:String = daSong?.albumId;
     if (albumRoll.albumId != newAlbumId)
     {
       albumRoll.albumId = newAlbumId;
-      albumRoll.playIntro();
+      albumRoll.skipIntro();
     }
   }
 
@@ -1103,6 +1125,12 @@ class FreeplayState extends MusicBeatSubState
           targetVariation: targetVariation,
           practiceMode: false,
           minimalMode: false,
+
+          #if (debug || FORCE_DEBUG_VERSION)
+          botPlayMode: FlxG.keys.pressed.SHIFT,
+          #else
+          botPlayMode: false,
+          #end
           // TODO: Make these an option! It's currently only accessible via chart editor.
           // startTimestamp: 0.0,
           // playbackRate: 0.5,
@@ -1115,20 +1143,18 @@ class FreeplayState extends MusicBeatSubState
   {
     if (rememberedSongId != null)
     {
-      curSelected = songs.findIndex(function(song) {
+      curSelected = currentFilteredSongs.findIndex(function(song) {
         if (song == null) return false;
         return song.songId == rememberedSongId;
       });
+
+      if (curSelected == -1) curSelected = 0;
     }
 
     if (rememberedDifficulty != null)
     {
       currentDifficulty = rememberedDifficulty;
     }
-
-    // Set the difficulty star count on the right.
-    var daSong:Null<FreeplaySongData> = songs[curSelected];
-    albumRoll.setDifficultyStars(daSong?.songRating ?? 0);
   }
 
   function changeSelection(change:Int = 0):Void
@@ -1156,8 +1182,10 @@ class FreeplayState extends MusicBeatSubState
     {
       intendedScore = 0;
       intendedCompletion = 0.0;
+      diffIdsCurrent = diffIdsTotal;
       rememberedSongId = null;
       rememberedDifficulty = null;
+      albumRoll.albumId = null;
     }
 
     for (index => capsule in grpCapsules.members)
@@ -1195,12 +1223,33 @@ class FreeplayState extends MusicBeatSubState
           });
         if (didReplace)
         {
+          FunkinSound.playMusic('freakyMenu',
+            {
+              startingVolume: 0.0,
+              overrideExisting: true,
+              restartTrack: false
+            });
           FlxG.sound.music.fadeIn(2, 0, 0.8);
         }
       }
       grpCapsules.members[curSelected].selected = true;
     }
   }
+
+  /**
+   * Build an instance of `FreeplayState` that is above the `MainMenuState`.
+   * @return The MainMenuState with the FreeplayState as a substate.
+   */
+  public static function build(?params:FreeplayStateParams, ?stickers:StickerSubState):MusicBeatState
+  {
+    var result = new MainMenuState();
+    result.persistentUpdate = false;
+    result.persistentDraw = true;
+
+    result.openSubState(new FreeplayState(params, stickers));
+
+    return result;
+  }
 }
 
 /**
@@ -1307,7 +1356,7 @@ class FreeplaySongData
   public var songName(default, null):String = '';
   public var songCharacter(default, null):String = '';
   public var songRating(default, null):Int = 0;
-  public var albumId(default, null):String = '';
+  public var albumId(default, null):Null<String> = null;
 
   public var currentDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY;
   public var displayedVariations(default, null):Array<String> = [Constants.DEFAULT_VARIATION];
@@ -1333,7 +1382,7 @@ class FreeplaySongData
 
   function updateValues(variations:Array<String>):Void
   {
-    this.songDifficulties = song.listDifficulties(variations);
+    this.songDifficulties = song.listDifficulties(variations, false, false);
     if (!this.songDifficulties.contains(currentDifficulty)) currentDifficulty = Constants.DEFAULT_DIFFICULTY;
 
     var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty, variations);
@@ -1341,7 +1390,15 @@ class FreeplaySongData
     this.songName = songDifficulty.songName;
     this.songCharacter = songDifficulty.characters.opponent;
     this.songRating = songDifficulty.difficultyRating;
-    this.albumId = songDifficulty.album;
+    if (songDifficulty.album == null)
+    {
+      FlxG.log.warn('No album for: ${songDifficulty.songName}');
+      this.albumId = Constants.DEFAULT_ALBUM_ID;
+    }
+    else
+    {
+      this.albumId = songDifficulty.album;
+    }
   }
 }
 
diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx
index bffa821b3..f8b3d7ac3 100644
--- a/source/funkin/ui/freeplay/SongMenuItem.hx
+++ b/source/funkin/ui/freeplay/SongMenuItem.hx
@@ -83,10 +83,10 @@ class SongMenuItem extends FlxSpriteGroup
 
     diffRatingSprite = new FlxSprite(145, 90).loadGraphic(Paths.image('freeplay/diffRatings/diff00'));
     diffRatingSprite.shader = grayscaleShader;
+    diffRatingSprite.origin.set(capsule.origin.x - diffRatingSprite.x, capsule.origin.y - diffRatingSprite.y);
     // TODO: Readd once ratings are fully implemented
     // add(diffRatingSprite);
-    diffRatingSprite.origin.set(capsule.origin.x - diffRatingSprite.x, capsule.origin.y - diffRatingSprite.y);
-    grpHide.add(diffRatingSprite);
+    // grpHide.add(diffRatingSprite);
 
     songText = new CapsuleText(capsule.width * 0.26, 45, 'Random', Std.int(40 * realScaled));
     add(songText);
diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index a8c2039ab..d2d8adeca 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -1,5 +1,6 @@
 package funkin.ui.mainmenu;
 
+import funkin.graphics.FunkinSprite;
 import flixel.addons.transition.FlxTransitionableState;
 import funkin.ui.debug.DebugMenuSubState;
 import flixel.FlxObject;
@@ -51,12 +52,10 @@ class MainMenuState extends MusicBeatState
     transIn = FlxTransitionableState.defaultTransIn;
     transOut = FlxTransitionableState.defaultTransOut;
 
-    if (!(FlxG?.sound?.music?.playing ?? false))
-    {
-      playMenuMusic();
-    }
+    playMenuMusic();
 
-    persistentUpdate = persistentDraw = true;
+    persistentUpdate = false;
+    persistentDraw = true;
 
     var bg:FlxSprite = new FlxSprite(Paths.image('menuBG'));
     bg.scrollFactor.x = 0;
@@ -69,7 +68,7 @@ class MainMenuState extends MusicBeatState
     camFollow = new FlxObject(0, 0, 1, 1);
     add(camFollow);
 
-    magenta = new FlxSprite(Paths.image('menuDesat'));
+    magenta = new FlxSprite(Paths.image('menuBGMagenta'));
     magenta.scrollFactor.x = bg.scrollFactor.x;
     magenta.scrollFactor.y = bg.scrollFactor.y;
     magenta.setGraphicSize(Std.int(bg.width));
@@ -77,7 +76,6 @@ class MainMenuState extends MusicBeatState
     magenta.x = bg.x;
     magenta.y = bg.y;
     magenta.visible = false;
-    magenta.color = 0xFFfd719b;
 
     // TODO: Why doesn't this line compile I'm going fucking feral
 
@@ -109,14 +107,21 @@ class MainMenuState extends MusicBeatState
     });
 
     #if CAN_OPEN_LINKS
+    // In order to prevent popup blockers from triggering,
+    // we need to open the link as an immediate result of a keypress event,
+    // so we can't wait for the flicker animation to complete.
     var hasPopupBlocker = #if web true #else false #end;
-    createMenuItem('donate', 'mainmenu/donate', selectDonate, hasPopupBlocker);
+    createMenuItem('merch', 'mainmenu/merch', selectMerch, hasPopupBlocker);
     #end
 
     createMenuItem('options', 'mainmenu/options', function() {
       startExitState(() -> new funkin.ui.options.OptionsState());
     });
 
+    createMenuItem('credits', 'mainmenu/credits', function() {
+      startExitState(() -> new funkin.ui.credits.CreditsState());
+    });
+
     // Reset position of menu items.
     var spacing = 160;
     var top = (FlxG.height - (spacing * (menuItems.length - 1))) / 2;
@@ -125,6 +130,9 @@ class MainMenuState extends MusicBeatState
       var menuItem = menuItems.members[i];
       menuItem.x = FlxG.width / 2;
       menuItem.y = top + spacing * i;
+      menuItem.scrollFactor.x = 0.0;
+      // This one affects how much the menu items move when you scroll between them.
+      menuItem.scrollFactor.y = 0.4;
     }
 
     resetCamStuff();
@@ -166,6 +174,7 @@ class MainMenuState extends MusicBeatState
   {
     FlxG.cameras.reset(new FunkinCamera());
     FlxG.camera.follow(camFollow, null, 0.06);
+    FlxG.camera.snapToTarget();
   }
 
   function createMenuItem(name:String, atlas:String, callback:Void->Void, fireInstantly:Bool = false):Void
@@ -212,6 +221,11 @@ class MainMenuState extends MusicBeatState
   {
     WindowUtil.openURL(Constants.URL_ITCH);
   }
+
+  function selectMerch()
+  {
+    WindowUtil.openURL(Constants.URL_MERCH);
+  }
   #end
 
   #if newgrounds
@@ -311,8 +325,6 @@ class MainMenuState extends MusicBeatState
     // Open the debug menu, defaults to ` / ~
     if (controls.DEBUG_MENU)
     {
-      this.persistentUpdate = false;
-      this.persistentDraw = false;
       FlxG.state.openSubState(new DebugMenuSubState());
     }
 
diff --git a/source/funkin/ui/options/OptionsState.hx b/source/funkin/ui/options/OptionsState.hx
index 0f33a0780..a00b28dbb 100644
--- a/source/funkin/ui/options/OptionsState.hx
+++ b/source/funkin/ui/options/OptionsState.hx
@@ -23,8 +23,7 @@ class OptionsState extends MusicBeatState
 
   override function create()
   {
-    var menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat'));
-    menuBG.color = 0xFFea71fd;
+    var menuBG = new FlxSprite().loadGraphic(Paths.image('menuBGBlue'));
     menuBG.setGraphicSize(Std.int(menuBG.width * 1.1));
     menuBG.updateHitbox();
     menuBG.screenCenter();
diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx
index 626fb8e52..8f454aa1a 100644
--- a/source/funkin/ui/story/Level.hx
+++ b/source/funkin/ui/story/Level.hx
@@ -169,7 +169,7 @@ class Level implements IRegistryEntry<LevelData>
     if (firstSong != null)
     {
       // Don't display alternate characters in Story Mode. Only show `default` and `erect` variations.
-      for (difficulty in firstSong.listDifficulties([Constants.DEFAULT_VARIATION, 'erect']))
+      for (difficulty in firstSong.listDifficulties([Constants.DEFAULT_VARIATION, 'erect'], false, false))
       {
         difficulties.push(difficulty);
       }
diff --git a/source/funkin/ui/title/AttractState.hx b/source/funkin/ui/title/AttractState.hx
index 0af97afd9..a42a6c3d9 100644
--- a/source/funkin/ui/title/AttractState.hx
+++ b/source/funkin/ui/title/AttractState.hx
@@ -17,7 +17,7 @@ import funkin.ui.MusicBeatState;
  */
 class AttractState extends MusicBeatState
 {
-  static final ATTRACT_VIDEO_PATH:String = Paths.videos('kickstarterTrailer');
+  static final ATTRACT_VIDEO_PATH:String = Paths.stripLibrary(Paths.videos('kickstarterTrailer', 'shared'));
 
   public override function create():Void
   {
@@ -29,10 +29,12 @@ class AttractState extends MusicBeatState
     }
 
     #if html5
+    trace('Playing web video ${ATTRACT_VIDEO_PATH}');
     playVideoHTML5(ATTRACT_VIDEO_PATH);
     #end
 
     #if hxCodec
+    trace('Playing native video ${ATTRACT_VIDEO_PATH}');
     playVideoNative(ATTRACT_VIDEO_PATH);
     #end
   }
diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index 1a4e13ab1..e76e66003 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -220,7 +220,7 @@ class TitleState extends MusicBeatState
 
   function playMenuMusic():Void
   {
-    var shouldFadeIn = (FlxG.sound.music == null);
+    var shouldFadeIn:Bool = (FlxG.sound.music == null);
     // Load music. Includes logic to handle BPM changes.
     FunkinSound.playMusic('freakyMenu',
       {
@@ -229,7 +229,7 @@ class TitleState extends MusicBeatState
         restartTrack: true
       });
     // Fade from 0.0 to 0.7 over 4 seconds
-    if (shouldFadeIn) FlxG.sound.music.fadeIn(4.0, 0.0, 0.7);
+    if (shouldFadeIn) FlxG.sound.music.fadeIn(4.0, 0.0, 1.0);
   }
 
   function getIntroTextShit():Array<Array<String>>
@@ -290,18 +290,6 @@ class TitleState extends MusicBeatState
     // do controls.PAUSE | controls.ACCEPT instead?
     var pressedEnter:Bool = FlxG.keys.justPressed.ENTER;
 
-    if (FlxG.onMobile)
-    {
-      for (touch in FlxG.touches.list)
-      {
-        if (touch.justPressed)
-        {
-          FlxG.switchState(() -> new FreeplayState());
-          pressedEnter = true;
-        }
-      }
-    }
-
     var gamepad:FlxGamepad = FlxG.gamepads.lastActive;
 
     if (gamepad != null)
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index 980c264e3..347190993 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -22,10 +22,12 @@ import openfl.filters.ShaderFilter;
 import openfl.utils.Assets;
 import flixel.util.typeLimit.NextState;
 
-class LoadingState extends MusicBeatState
+class LoadingState extends MusicBeatSubState
 {
   inline static var MIN_TIME = 1.0;
 
+  var asSubState:Bool = false;
+
   var target:NextState;
   var playParams:Null<PlayStateParams>;
   var stopMusic:Bool = false;
@@ -178,7 +180,16 @@ class LoadingState extends MusicBeatState
       FlxG.sound.music = null;
     }
 
-    FlxG.switchState(target);
+    if (asSubState)
+    {
+      this.close();
+      // We will assume the target is a valid substate.
+      FlxG.state.openSubState(cast target);
+    }
+    else
+    {
+      FlxG.switchState(target);
+    }
   }
 
   static function getSongPath():String
@@ -190,17 +201,41 @@ class LoadingState extends MusicBeatState
    * Starts the transition to a new `PlayState` to start a new song.
    * First switches to the `LoadingState` if assets need to be loaded.
    * @param params The parameters for the next `PlayState`.
+   * @param asSubState Whether to open as a substate rather than switching to the `PlayState`.
    * @param shouldStopMusic Whether to stop the current music while loading.
    */
-  public static function loadPlayState(params:PlayStateParams, shouldStopMusic = false):Void
+  public static function loadPlayState(params:PlayStateParams, shouldStopMusic = false, asSubState = false, ?onConstruct:PlayState->Void):Void
   {
     Paths.setCurrentLevel(PlayStatePlaylist.campaignId);
-    var playStateCtor:NextState = () -> new PlayState(params);
+    var playStateCtor:() -> PlayState = function() {
+      return new PlayState(params);
+    };
+
+    if (onConstruct != null)
+    {
+      playStateCtor = function() {
+        var result = new PlayState(params);
+        onConstruct(result);
+        return result;
+      };
+    }
 
     #if NO_PRELOAD_ALL
     // Switch to loading state while we load assets (default on HTML5 target).
-    var loadStateCtor:NextState = () -> new LoadingState(playStateCtor, shouldStopMusic, params);
-    FlxG.switchState(loadStateCtor);
+    var loadStateCtor = function() {
+      var result = new LoadingState(playStateCtor, shouldStopMusic, params);
+      @:privateAccess
+      result.asSubState = asSubState;
+      return result;
+    }
+    if (asSubState)
+    {
+      FlxG.state.openSubState(cast loadStateCtor());
+    }
+    else
+    {
+      FlxG.switchState(loadStateCtor);
+    }
     #else
     // All assets preloaded, switch directly to play state (defualt on other targets).
     if (shouldStopMusic && FlxG.sound.music != null)
@@ -210,14 +245,42 @@ class LoadingState extends MusicBeatState
     }
 
     // Load and cache the song's charts.
-    if (params?.targetSong != null)
+    // Don't do this if we already provided the music and charts.
+    if (params?.targetSong != null && !params.overrideMusic)
     {
       params.targetSong.cacheCharts(true);
     }
 
+    var shouldPreloadLevelAssets:Bool = !(params?.minimalMode ?? false);
+
+    if (shouldPreloadLevelAssets) preloadLevelAssets();
+
+    if (asSubState)
+    {
+      FlxG.state.openSubState(cast playStateCtor());
+    }
+    else
+    {
+      FlxG.switchState(playStateCtor);
+    }
+    #end
+  }
+
+  #if NO_PRELOAD_ALL
+  static function isSoundLoaded(path:String):Bool
+  {
+    return Assets.cache.hasSound(path);
+  }
+
+  static function isLibraryLoaded(library:String):Bool
+  {
+    return Assets.getLibrary(library) != null;
+  }
+  #else
+  static function preloadLevelAssets():Void
+  {
     // TODO: This section is a hack! Redo this later when we have a proper asset caching system.
     FunkinSprite.preparePurgeCache();
-    FunkinSprite.cacheTexture(Paths.image('combo'));
     FunkinSprite.cacheTexture(Paths.image('healthBar'));
     FunkinSprite.cacheTexture(Paths.image('menuDesat'));
     FunkinSprite.cacheTexture(Paths.image('combo'));
@@ -247,7 +310,10 @@ class LoadingState extends MusicBeatState
     // List all image assets in the level's library.
     // This is crude and I want to remove it when we have a proper asset caching system.
     // TODO: Get rid of this junk!
-    var library = openfl.utils.Assets.getLibrary(PlayStatePlaylist.campaignId);
+    var library = PlayStatePlaylist.campaignId != null ? openfl.utils.Assets.getLibrary(PlayStatePlaylist.campaignId) : null;
+
+    if (library == null) return; // We don't need to do anymore precaching.
+
     var assets = library.list(lime.utils.AssetType.IMAGE);
     trace('Got ${assets.length} assets: ${assets}');
 
@@ -278,20 +344,6 @@ class LoadingState extends MusicBeatState
     // FunkinSprite.cacheAllSongTextures(stage)
 
     FunkinSprite.purgeCache();
-
-    FlxG.switchState(playStateCtor);
-    #end
-  }
-
-  #if NO_PRELOAD_ALL
-  static function isSoundLoaded(path:String):Bool
-  {
-    return Assets.cache.hasSound(path);
-  }
-
-  static function isLibraryLoaded(library:String):Bool
-  {
-    return Assets.getLibrary(library) != null;
   }
   #end
 
diff --git a/source/funkin/ui/transition/preload/FunkinPreloader.hx b/source/funkin/ui/transition/preload/FunkinPreloader.hx
index 89a8c1140..9f509df9c 100644
--- a/source/funkin/ui/transition/preload/FunkinPreloader.hx
+++ b/source/funkin/ui/transition/preload/FunkinPreloader.hx
@@ -1,5 +1,10 @@
 package funkin.ui.transition.preload;
 
+import openfl.filters.GlowFilter;
+import openfl.display.SpreadMethod;
+import openfl.display.GradientType;
+import openfl.geom.Matrix;
+import openfl.filters.BlurFilter;
 import openfl.events.MouseEvent;
 import flash.display.Bitmap;
 import flash.display.BitmapData;
@@ -46,7 +51,7 @@ class FunkinPreloader extends FlxBasePreloader
    */
   static final BAR_PADDING:Float = 20;
 
-  static final BAR_HEIGHT:Int = 20;
+  static final BAR_HEIGHT:Int = 12;
 
   /**
    * Logo takes this long (in seconds) to fade in.
@@ -108,13 +113,22 @@ class FunkinPreloader extends FlxBasePreloader
   #if TOUCH_HERE_TO_PLAY
   var touchHereToPlay:Bitmap;
   #end
+  var progressBarPieces:Array<Sprite>;
   var progressBar:Bitmap;
   var progressLeftText:TextField;
   var progressRightText:TextField;
 
+  var dspText:TextField;
+  var enhancedText:TextField;
+  var stereoText:TextField;
+
+  var vfdShader:VFDOverlay;
+  var box:Sprite;
+  var progressLines:Sprite;
+
   public function new()
   {
-    super(Constants.PRELOADER_MIN_STAGE_TIME, Constants.SITE_LOCK);
+    super(Constants.PRELOADER_MIN_STAGE_TIME);
 
     // We can't even call trace() yet, until Flixel loads.
     trace('Initializing custom preloader...');
@@ -146,7 +160,7 @@ class FunkinPreloader extends FlxBasePreloader
       bmp.x = (this._width - bmp.width) / 2;
       bmp.y = (this._height - bmp.height) / 2;
     });
-    addChild(logo);
+    // addChild(logo);
 
     #if TOUCH_HERE_TO_PLAY
     touchHereToPlay = createBitmap(TouchHereToPlayImage, function(bmp:Bitmap) {
@@ -160,16 +174,48 @@ class FunkinPreloader extends FlxBasePreloader
     addChild(touchHereToPlay);
     #end
 
+    var amountOfPieces:Int = 16;
+    progressBarPieces = [];
+    var maxBarWidth = this._width - BAR_PADDING * 2;
+    var pieceWidth = maxBarWidth / amountOfPieces;
+    var pieceGap:Int = 8;
+
+    progressLines = new openfl.display.Sprite();
+    progressLines.graphics.lineStyle(2, Constants.COLOR_PRELOADER_BAR);
+    progressLines.graphics.drawRect(-2, 480, this._width + 4, 30);
+    addChild(progressLines);
+
+    var progressBarPiece = new Sprite();
+    progressBarPiece.graphics.beginFill(Constants.COLOR_PRELOADER_BAR);
+    progressBarPiece.graphics.drawRoundRect(0, 0, pieceWidth - pieceGap, BAR_HEIGHT, 4, 4);
+    progressBarPiece.graphics.endFill();
+
+    for (i in 0...amountOfPieces)
+    {
+      var piece = new Sprite();
+      piece.graphics.beginFill(Constants.COLOR_PRELOADER_BAR);
+      piece.graphics.drawRoundRect(0, 0, pieceWidth - pieceGap, BAR_HEIGHT, 4, 4);
+      piece.graphics.endFill();
+
+      piece.x = i * (piece.width + pieceGap);
+      piece.y = this._height - BAR_PADDING - BAR_HEIGHT - 200;
+      addChild(piece);
+      progressBarPieces.push(piece);
+    }
+
     // Create the progress bar.
-    progressBar = new Bitmap(new BitmapData(1, BAR_HEIGHT, true, Constants.COLOR_PRELOADER_BAR));
-    progressBar.x = BAR_PADDING;
-    progressBar.y = this._height - BAR_PADDING - BAR_HEIGHT;
-    addChild(progressBar);
+    // progressBar = new Bitmap(new BitmapData(1, BAR_HEIGHT, true, Constants.COLOR_PRELOADER_BAR));
+    // progressBar.x = BAR_PADDING;
+    // progressBar.y = this._height - BAR_PADDING - BAR_HEIGHT;
+    // addChild(progressBar);
 
     // Create the progress message.
     progressLeftText = new TextField();
+    dspText = new TextField();
+    enhancedText = new TextField();
+    stereoText = new TextField();
 
-    var progressLeftTextFormat = new TextFormat("VCR OSD Mono", 16, Constants.COLOR_PRELOADER_BAR, true);
+    var progressLeftTextFormat = new TextFormat("DS-Digital", 32, Constants.COLOR_PRELOADER_BAR, true);
     progressLeftTextFormat.align = TextFormatAlign.LEFT;
     progressLeftText.defaultTextFormat = progressLeftTextFormat;
 
@@ -177,13 +223,14 @@ class FunkinPreloader extends FlxBasePreloader
     progressLeftText.width = this._width - BAR_PADDING * 2;
     progressLeftText.text = 'Downloading assets...';
     progressLeftText.x = BAR_PADDING;
-    progressLeftText.y = this._height - BAR_PADDING - BAR_HEIGHT - 16 - 4;
+    progressLeftText.y = this._height - BAR_PADDING - BAR_HEIGHT - 290;
+    // progressLeftText.shader = new VFDOverlay();
     addChild(progressLeftText);
 
     // Create the progress %.
     progressRightText = new TextField();
 
-    var progressRightTextFormat = new TextFormat("VCR OSD Mono", 16, Constants.COLOR_PRELOADER_BAR, true);
+    var progressRightTextFormat = new TextFormat("DS-Digital", 16, Constants.COLOR_PRELOADER_BAR, true);
     progressRightTextFormat.align = TextFormatAlign.RIGHT;
     progressRightText.defaultTextFormat = progressRightTextFormat;
 
@@ -193,6 +240,60 @@ class FunkinPreloader extends FlxBasePreloader
     progressRightText.x = BAR_PADDING;
     progressRightText.y = this._height - BAR_PADDING - BAR_HEIGHT - 16 - 4;
     addChild(progressRightText);
+
+    box = new Sprite();
+    box.graphics.beginFill(Constants.COLOR_PRELOADER_BAR, 1);
+    box.graphics.drawRoundRect(0, 0, 64, 20, 5, 5);
+    box.graphics.drawRoundRect(70, 0, 58, 20, 5, 5);
+    box.graphics.endFill();
+    box.graphics.beginFill(Constants.COLOR_PRELOADER_BAR, 0.1);
+    box.graphics.drawRoundRect(0, 0, 128, 20, 5, 5);
+    box.graphics.endFill();
+    box.x = 880;
+    box.y = 440;
+    addChild(box);
+
+    dspText.selectable = false;
+    dspText.textColor = 0x000000;
+    dspText.width = this._width;
+    dspText.height = 20;
+    dspText.text = 'DSP';
+    dspText.x = 10;
+    dspText.y = -5;
+    box.addChild(dspText);
+
+    enhancedText.selectable = false;
+    enhancedText.textColor = Constants.COLOR_PRELOADER_BAR;
+    enhancedText.width = this._width;
+    enhancedText.height = 100;
+    enhancedText.text = 'ENHANCED';
+    enhancedText.x = -100;
+    enhancedText.y = 0;
+    box.addChild(enhancedText);
+
+    stereoText.selectable = false;
+    stereoText.textColor = Constants.COLOR_PRELOADER_BAR;
+    stereoText.width = this._width;
+    stereoText.height = 100;
+    stereoText.text = 'STEREO';
+    stereoText.x = 0;
+    stereoText.y = -40;
+    box.addChild(stereoText);
+
+    // var dummyMatrix:openfl.geom.Matrix = new Matrix();
+    // dummyMatrix.createGradientBox(this._width, this._height * 0.1, 90 * Math.PI / 180);
+
+    // var gradient:Sprite = new Sprite();
+    // gradient.graphics.beginGradientFill(GradientType.LINEAR, [0xFFFFFF, 0x000000], [1, 1], [0, 255], dummyMatrix, SpreadMethod.REFLECT);
+    // gradient.graphics.drawRect(0, 0, this._width, this._height);
+    // gradient.graphics.endFill();
+    // addChild(gradient);
+
+    var vfdBitmap:Bitmap = new Bitmap(new BitmapData(this._width, this._height, true, 0xFFFFFFFF));
+    addChild(vfdBitmap);
+
+    vfdShader = new VFDOverlay();
+    vfdBitmap.shader = vfdShader;
   }
 
   var lastElapsed:Float = 0.0;
@@ -200,6 +301,8 @@ class FunkinPreloader extends FlxBasePreloader
   override function update(percent:Float):Void
   {
     var elapsed:Float = (Date.now().getTime() - this._startTime) / 1000.0;
+
+    vfdShader.update(elapsed * 100);
     // trace('Time since last frame: ' + (lastElapsed - elapsed));
 
     downloadingAssetsPercent = percent;
@@ -748,12 +851,19 @@ class FunkinPreloader extends FlxBasePreloader
     else
     {
       renderLogoFadeIn(elapsed);
+
+      // Render progress bar
+      var maxWidth = this._width - BAR_PADDING * 2;
+      var barWidth = maxWidth * percent;
+      var piecesToRender:Int = Std.int(percent * progressBarPieces.length);
+
+      for (i => piece in progressBarPieces)
+      {
+        piece.alpha = i <= piecesToRender ? 0.9 : 0.1;
+      }
     }
 
-    // Render progress bar
-    var maxWidth = this._width - BAR_PADDING * 2;
-    var barWidth = maxWidth * percent;
-    progressBar.width = barWidth;
+    // progressBar.width = barWidth;
 
     // Cycle ellipsis count to show loading
     var ellipsisCount:Int = Std.int(elapsed / ELLIPSIS_TIME) % 3 + 1;
@@ -766,29 +876,29 @@ class FunkinPreloader extends FlxBasePreloader
     {
       // case FunkinPreloaderState.NotStarted:
       default:
-        updateProgressLeftText('Loading (0/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Loading \n0/$TOTAL_STEPS $ellipsis');
       case FunkinPreloaderState.DownloadingAssets:
-        updateProgressLeftText('Downloading assets (1/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Downloading assets \n1/$TOTAL_STEPS $ellipsis');
       case FunkinPreloaderState.PreloadingPlayAssets:
-        updateProgressLeftText('Preloading assets (2/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Preloading assets \n2/$TOTAL_STEPS $ellipsis');
       case FunkinPreloaderState.InitializingScripts:
-        updateProgressLeftText('Initializing scripts (3/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Initializing scripts \n3/$TOTAL_STEPS $ellipsis');
       case FunkinPreloaderState.CachingGraphics:
-        updateProgressLeftText('Caching graphics (4/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Caching graphics \n4/$TOTAL_STEPS $ellipsis');
       case FunkinPreloaderState.CachingAudio:
-        updateProgressLeftText('Caching audio (5/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Caching audio \n5/$TOTAL_STEPS $ellipsis');
       case FunkinPreloaderState.CachingData:
-        updateProgressLeftText('Caching data (6/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Caching data \n6/$TOTAL_STEPS $ellipsis');
       case FunkinPreloaderState.ParsingSpritesheets:
-        updateProgressLeftText('Parsing spritesheets (7/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Parsing spritesheets \n7/$TOTAL_STEPS $ellipsis');
       case FunkinPreloaderState.ParsingStages:
-        updateProgressLeftText('Parsing stages (8/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Parsing stages \n8/$TOTAL_STEPS $ellipsis');
       case FunkinPreloaderState.ParsingCharacters:
-        updateProgressLeftText('Parsing characters (9/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Parsing characters \n9/$TOTAL_STEPS $ellipsis');
       case FunkinPreloaderState.ParsingSongs:
-        updateProgressLeftText('Parsing songs (10/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Parsing songs \n10/$TOTAL_STEPS $ellipsis');
       case FunkinPreloaderState.Complete:
-        updateProgressLeftText('Finishing up ($TOTAL_STEPS/$TOTAL_STEPS)$ellipsis');
+        updateProgressLeftText('Finishing up \n$TOTAL_STEPS/$TOTAL_STEPS $ellipsis');
       #if TOUCH_HERE_TO_PLAY
       case FunkinPreloaderState.TouchHereToPlay:
         updateProgressLeftText(null);
@@ -815,10 +925,21 @@ class FunkinPreloader extends FlxBasePreloader
       else if (progressLeftText.text != text)
       {
         // We have to keep updating the text format, because the font can take a frame or two to load.
-        var progressLeftTextFormat = new TextFormat("VCR OSD Mono", 16, Constants.COLOR_PRELOADER_BAR, true);
+        var progressLeftTextFormat = new TextFormat("DS-Digital", 32, Constants.COLOR_PRELOADER_BAR, true);
         progressLeftTextFormat.align = TextFormatAlign.LEFT;
         progressLeftText.defaultTextFormat = progressLeftTextFormat;
         progressLeftText.text = text;
+
+        dspText.defaultTextFormat = new TextFormat("Quantico", 20, 0x000000, false);
+        dspText.text = 'DSP\t\t\t\t\tFNF'; // fukin dum....
+        dspText.textColor = 0x000000;
+
+        enhancedText.defaultTextFormat = new TextFormat("Inconsolata Black", 16, Constants.COLOR_PRELOADER_BAR, false);
+        enhancedText.text = 'ENHANCED';
+        enhancedText.textColor = Constants.COLOR_PRELOADER_BAR;
+
+        stereoText.defaultTextFormat = new TextFormat("Inconsolata Bold", 36, Constants.COLOR_PRELOADER_BAR, false);
+        stereoText.text = 'NATURAL STEREO';
       }
     }
   }
@@ -845,9 +966,17 @@ class FunkinPreloader extends FlxBasePreloader
     logo.y = (this._height - logo.height) / 2;
 
     // Fade out progress bar too.
-    progressBar.alpha = logo.alpha;
+    // progressBar.alpha = logo.alpha;
     progressLeftText.alpha = logo.alpha;
     progressRightText.alpha = logo.alpha;
+    box.alpha = logo.alpha;
+    dspText.alpha = logo.alpha;
+    enhancedText.alpha = logo.alpha;
+    stereoText.alpha = logo.alpha;
+    progressLines.alpha = logo.alpha;
+
+    for (piece in progressBarPieces)
+      piece.alpha = logo.alpha;
 
     return elapsedFinished;
   }
@@ -901,8 +1030,8 @@ class FunkinPreloader extends FlxBasePreloader
   {
     // Ensure the graphics are properly destroyed and GC'd.
     removeChild(logo);
-    removeChild(progressBar);
-    logo = progressBar = null;
+    // removeChild(progressBar);
+    logo = null;
     super.destroy();
   }
 
diff --git a/source/funkin/ui/transition/preload/VFDOverlay.hx b/source/funkin/ui/transition/preload/VFDOverlay.hx
new file mode 100644
index 000000000..1792c56ec
--- /dev/null
+++ b/source/funkin/ui/transition/preload/VFDOverlay.hx
@@ -0,0 +1,70 @@
+package funkin.ui.transition.preload;
+
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
+import openfl.display.GraphicsShader;
+
+class VFDOverlay extends GraphicsShader
+{
+  public var elapsedTime(default, set):Float = 0;
+
+  function set_elapsedTime(value:Float):Float
+  {
+    u_time.value = [value];
+    return value;
+  }
+
+  @:glFragmentSource('#pragma header
+    const vec2 s = vec2(1, 1.7320508);
+
+    uniform float u_time;
+
+    float rand(float co) { return fract(sin(co*(91.3458)) * 47453.5453); }
+    float rand(vec2 co){ return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453); }
+
+    void main(void) {
+      vec4 col = texture2D (bitmap, openfl_TextureCoordv);
+      vec2 game_res = vec2(1280.0, 720.0);
+      const float tileAmount = 10.;
+
+      vec2 uv = (2. * openfl_TextureCoordv.xy * -1.);
+      uv *= 50.;
+
+      vec4 hexCenter = floor(vec4(uv, uv - vec2(0.5, 1.0)) / s.xyxy) + 0.5;
+      vec4 offset = vec4(uv - hexCenter.xy * s, uv - (hexCenter.zw + 0.5) * s) + 0.0;
+      vec4 hexInfo = dot(offset.xy, offset.xy) < dot(offset.zw, offset.zw) ? vec4(offset.xy, hexCenter.xy) : vec4(offset.zw, hexCenter.zw);
+
+      // Distance to the nearest edge of a hexagon
+      vec2 p = abs(hexInfo.xy) ;
+      float edgeDist = max(dot(p, normalize(vec2(1.0, sqrt(3.0)))), p.x);
+      float edgeWidth = 0.05 * tileAmount; // Adjust edge width based on tile amount
+      float edgeSharpness = 0.011 * tileAmount;
+
+      float outline = smoothstep(edgeWidth - edgeSharpness, edgeWidth, edgeDist);
+      float color_mix = mix(0.0, 0.3, outline); // Mix black outline with white fill
+
+      float flicker = (sin(u_time) * 0.05) + 1.0;
+      float sinshit = smoothstep(-3.0, 1.0, sin(uv.y * 3.));
+
+      col = vec4(vec3(0.0), color_mix);
+      col = mix(col, vec4(0., 0., 0., sinshit), 0.5 * flicker);
+
+      float specs = rand(uv.xy);
+      vec4 noise = vec4(0., 0., 0., specs);
+      col = mix(col, noise, 0.1);
+
+      gl_FragColor = col;
+		}
+  ')
+  public function new()
+  {
+    super();
+
+    this.elapsedTime = 0;
+  }
+
+  public function update(elapsed:Float):Void
+  {
+    this.elapsedTime += elapsed;
+  }
+}
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index 5d355f2da..75766a75a 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -60,6 +60,11 @@ class Constants
    */
   // ==============================
 
+  /**
+   * Link to buy merch for the game.
+   */
+  public static final URL_MERCH:String = 'https://needlejuicerecords.com/pages/friday-night-funkin';
+
   /**
    * Preloader sitelock.
    * Matching is done by `FlxStringUtil.getDomain`, so any URL on the domain will work.
@@ -135,7 +140,7 @@ class Constants
   /**
    * Color for the preloader progress bar
    */
-  public static final COLOR_PRELOADER_BAR:FlxColor = 0xFF00FF00;
+  public static final COLOR_PRELOADER_BAR:FlxColor = 0xFFA4FF11;
 
   /**
    * Color for the preloader site lock background
@@ -181,6 +186,12 @@ class Constants
    */
   public static final DEFAULT_DIFFICULTY_LIST:Array<String> = ['easy', 'normal', 'hard'];
 
+  /**
+   * List of all difficulties used by the base game.
+   * Includes Erect and Nightmare.
+   */
+  public static final DEFAULT_DIFFICULTY_LIST_FULL:Array<String> = ['easy', 'normal', 'hard', 'erect', 'nightmare'];
+
   /**
    * Default player character for charts.
    */
@@ -212,9 +223,10 @@ class Constants
   public static final DEFAULT_VARIATION_LIST:Array<String> = ['default', 'erect', 'pico'];
 
   /**
-   * The default intensity for camera zooms.
+   * The default intensity multiplier for camera bops.
+   * Prolly needs to be tuned bc it's a multiplier now.
    */
-  public static final DEFAULT_ZOOM_INTENSITY:Float = 0.015;
+  public static final DEFAULT_BOP_INTENSITY:Float = 1.015;
 
   /**
    * The default rate for camera zooms (in beats per zoom).
@@ -346,7 +358,7 @@ class Constants
    *     The progress bare is automatically rescaled to match.
    */
   #if debug
-  public static final PRELOADER_MIN_STAGE_TIME:Float = 1.0;
+  public static final PRELOADER_MIN_STAGE_TIME:Float = 0.0;
   #else
   public static final PRELOADER_MIN_STAGE_TIME:Float = 0.1;
   #end
@@ -514,4 +526,10 @@ class Constants
    * The vertical offset of the strumline from the top edge of the screen.
    */
   public static final STRUMLINE_Y_OFFSET:Float = 24;
+
+  /**
+   * The rate at which the camera lerps to its target.
+   * 0.04 = 4% of distance per frame.
+   */
+  public static final DEFAULT_CAMERA_FOLLOW_RATE:Float = 0.04;
 }
diff --git a/source/funkin/util/EaseUtil.hx b/source/funkin/util/EaseUtil.hx
new file mode 100644
index 000000000..200e74d07
--- /dev/null
+++ b/source/funkin/util/EaseUtil.hx
@@ -0,0 +1,17 @@
+package funkin.util;
+
+class EaseUtil
+{
+  /**
+   * Returns an ease function that eases via steps.
+   * Useful for "retro" style fades (week 6!)
+   * @param steps how many steps to ease over
+   * @return Float->Float
+   */
+  public static inline function stepped(steps:Int):Float->Float
+  {
+    return function(t:Float):Float {
+      return Math.floor(t * steps) / steps;
+    }
+  }
+}
diff --git a/source/funkin/util/SerializerUtil.hx b/source/funkin/util/SerializerUtil.hx
index c87d3f6c0..fa602cc73 100644
--- a/source/funkin/util/SerializerUtil.hx
+++ b/source/funkin/util/SerializerUtil.hx
@@ -63,6 +63,31 @@ class SerializerUtil
     }
   }
 
+  public static function initSerializer():Void
+  {
+    haxe.Unserializer.DEFAULT_RESOLVER = new FunkinTypeResolver();
+  }
+
+  /**
+   * Serialize a Haxe object using the built-in Serializer.
+   * @param input The object to serialize
+   * @return The serialized object as a string
+   */
+  public static function fromHaxeObject(input:Dynamic):String
+  {
+    return haxe.Serializer.run(input);
+  }
+
+  /**
+   * Convert a serialized Haxe object back into a Haxe object.
+   * @param input The serialized object as a string
+   * @return The deserialized object
+   */
+  public static function toHaxeObject(input:String):Dynamic
+  {
+    return haxe.Unserializer.run(input);
+  }
+
   /**
    * Customize how certain types are serialized when converting to JSON.
    */
@@ -90,3 +115,26 @@ class SerializerUtil
     return result;
   }
 }
+
+class FunkinTypeResolver
+{
+  public function new()
+  {
+    // Blank constructor.
+  }
+
+  public function resolveClass(name:String):Class<Dynamic>
+  {
+    if (name == 'Dynamic')
+    {
+      FlxG.log.warn('Found invalid class type in save data, indicates partial save corruption.');
+      return null;
+    }
+    return Type.resolveClass(name);
+  };
+
+  public function resolveEnum(name:String):Enum<Dynamic>
+  {
+    return Type.resolveEnum(name);
+  };
+}
diff --git a/source/funkin/util/StructureUtil.hx b/source/funkin/util/StructureUtil.hx
new file mode 100644
index 000000000..2f0c3818a
--- /dev/null
+++ b/source/funkin/util/StructureUtil.hx
@@ -0,0 +1,136 @@
+package funkin.util;
+
+import funkin.util.tools.MapTools;
+import haxe.DynamicAccess;
+
+/**
+ * Utilities for working with anonymous structures.
+ */
+class StructureUtil
+{
+  /**
+   * Merge two structures, with the second overwriting the first.
+   * Performs a SHALLOW clone, where child structures are not merged.
+   * @param a The base structure.
+   * @param b The new structure.
+   * @return The merged structure.
+   */
+  public static function merge(a:Dynamic, b:Dynamic):Dynamic
+  {
+    var result:DynamicAccess<Dynamic> = Reflect.copy(a);
+
+    for (field in Reflect.fields(b))
+    {
+      result.set(field, Reflect.field(b, field));
+    }
+
+    return result;
+  }
+
+  public static function toMap(a:Dynamic):haxe.ds.Map<String, Dynamic>
+  {
+    var result:haxe.ds.Map<String, Dynamic> = [];
+
+    for (field in Reflect.fields(a))
+    {
+      result.set(field, Reflect.field(a, field));
+    }
+
+    return result;
+  }
+
+  public static function isMap(a:Dynamic):Bool
+  {
+    return Std.isOfType(a, haxe.Constraints.IMap);
+  }
+
+  public static function isObject(a:Dynamic):Bool
+  {
+    switch (Type.typeof(a))
+    {
+      case TObject:
+        return true;
+      default:
+        return false;
+    }
+  }
+
+  public static function isPrimitive(a:Dynamic):Bool
+  {
+    switch (Type.typeof(a))
+    {
+      case TInt | TFloat | TBool:
+        return true;
+      case TClass(c):
+        return false;
+      case TEnum(e):
+        return false;
+      case TObject:
+        return false;
+      case TFunction:
+        return false;
+      case TNull:
+        return true;
+      case TUnknown:
+        return false;
+      default:
+        return false;
+    }
+  }
+
+  /**
+   * Merge two structures, with the second overwriting the first.
+   * Performs a DEEP clone, where child structures are also merged recursively.
+   * @param a The base structure.
+   * @param b The new structure.
+   * @return The merged structure.
+   */
+  public static function deepMerge(a:Dynamic, b:Dynamic):Dynamic
+  {
+    if (a == null) return b;
+    if (b == null) return null;
+    if (isPrimitive(a) && isPrimitive(b)) return b;
+    if (isMap(b))
+    {
+      if (isMap(a))
+      {
+        return MapTools.merge(a, b);
+      }
+      else
+      {
+        return StructureUtil.toMap(a).merge(b);
+      }
+    }
+    if (!Reflect.isObject(a) || !Reflect.isObject(b)) return b;
+    if (Std.isOfType(b, haxe.ds.StringMap))
+    {
+      if (Std.isOfType(a, haxe.ds.StringMap))
+      {
+        return MapTools.merge(a, b);
+      }
+      else
+      {
+        return StructureUtil.toMap(a).merge(b);
+      }
+    }
+
+    var result:DynamicAccess<Dynamic> = Reflect.copy(a);
+
+    for (field in Reflect.fields(b))
+    {
+      if (Reflect.isObject(b))
+      {
+        // Note that isObject also returns true for class instances,
+        // but we just assume that's not a problem here.
+        result.set(field, deepMerge(Reflect.field(result, field), Reflect.field(b, field)));
+      }
+      else
+      {
+        // If we're here, b[field] is a primitive.
+        result.set(field, Reflect.field(b, field));
+      }
+    }
+
+    return result;
+  }
+}
diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx
index 1399fb791..b98cb0adf 100644
--- a/source/funkin/util/tools/MapTools.hx
+++ b/source/funkin/util/tools/MapTools.hx
@@ -33,6 +33,24 @@ class MapTools
     return map.copy();
   }
 
+  /**
+   * Create a new map which is a combination of the two given maps.
+   * @param a The base map.
+   * @param b The other map. The values from this take precedence.
+   * @return The combined map.
+   */
+  public static function merge<K, T>(a:Map<K, T>, b:Map<K, T>):Map<K, T>
+  {
+    var result = a.copy();
+
+    for (pair in b.keyValueIterator())
+    {
+      result.set(pair.key, pair.value);
+    }
+
+    return result;
+  }
+
   /**
    * Create a new array with clones of all elements of the given array, to prevent modifying the original.
    */