diff --git a/Project.xml b/Project.xml
index 393248698..972749939 100644
--- a/Project.xml
+++ b/Project.xml
@@ -130,6 +130,7 @@
 	<haxelib name="polymod" /> <!-- Modding framework -->
 	<haxelib name="flxanimate" /> <!-- Texture atlas rendering -->
 	<!-- <haxelib name="hxcodec" /> Video playback -->
+	<haxelib name="json2object" /> <!-- JSON parsing -->
 
 	<haxelib name="thx.semver" />
 
@@ -144,6 +145,9 @@
 	<!--Enable this for Nape release builds for a serious peformance improvement-->
 	<haxedef name="NAPE_RELEASE_BUILD" unless="debug" />
 
+	<!-- TODO: REMOVE THIS!!!! -->
+	<haxeflag name="-w" value="-WDeprecated" />
+
 	<!-- _________________________________ Custom _______________________________ -->
 
 	<!-- Disable trace() calls in release builds to bump up performance. -->
diff --git a/hmm.json b/hmm.json
index bbfa4b058..9061d594b 100644
--- a/hmm.json
+++ b/hmm.json
@@ -86,6 +86,11 @@
       "type": "haxelib",
       "version": "1.2.2"
     },
+    {
+      "name": "json2object",
+      "type": "haxelib",
+      "version": null
+    },
     {
       "name": "lime",
       "type": "git",
diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx
index d1176fa03..a0493869b 100644
--- a/source/funkin/Conductor.hx
+++ b/source/funkin/Conductor.hx
@@ -207,9 +207,15 @@ class Conductor
     }
 
     // FlxSignals are really cool.
-    if (currentStep != oldStep) stepHit.dispatch();
+    if (currentStep != oldStep)
+    {
+      stepHit.dispatch();
+    }
 
-    if (currentBeat != oldBeat) beatHit.dispatch();
+    if (currentBeat != oldBeat)
+    {
+      beatHit.dispatch();
+    }
   }
 
   @:deprecated // Switch to TimeChanges instead.
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index fea8899d2..45c2645df 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -78,6 +78,7 @@ class InitState extends FlxTransitionableState
       }
     });
 
+    #if FLX_DEBUG
     FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFF2222CC), function() {
       FlxG.game.debugger.vcr.onStep();
 
@@ -90,6 +91,7 @@ class InitState extends FlxTransitionableState
       FlxG.sound.music.pause();
       FlxG.sound.music.time += FlxG.elapsed * 1000;
     });
+    #end
 
     FlxG.sound.muteKeys = [ZERO];
     FlxG.game.focusLostFramerate = 60;
@@ -153,6 +155,7 @@ class InitState extends FlxTransitionableState
 
     // TODO: Register custom event callbacks here
 
+    funkin.data.level.LevelRegistry.instance.loadEntries();
     SongEventParser.loadEventCache();
     SongDataParser.loadSongCache();
     StageDataParser.loadStageCache();
diff --git a/source/funkin/LatencyState.hx b/source/funkin/LatencyState.hx
index 256efde5c..04a14b90a 100644
--- a/source/funkin/LatencyState.hx
+++ b/source/funkin/LatencyState.hx
@@ -31,7 +31,7 @@ class LatencyState extends MusicBeatSubstate
   var offsetsPerBeat:Array<Int> = [];
   var swagSong:HomemadeMusic;
 
-  #if debug
+  #if FLX_DEBUG
   var funnyStatsGraph:CoolStatsGraph;
   var realStats:CoolStatsGraph;
   #end
@@ -44,7 +44,7 @@ class LatencyState extends MusicBeatSubstate
     FlxG.sound.music = swagSong;
     FlxG.sound.music.play();
 
-    #if debug
+    #if FLX_DEBUG
     funnyStatsGraph = new CoolStatsGraph(0, Std.int(FlxG.height / 2), FlxG.width, Std.int(FlxG.height / 2), FlxColor.PINK, "time");
     FlxG.addChildBelowMouse(funnyStatsGraph);
 
@@ -52,8 +52,7 @@ class LatencyState extends MusicBeatSubstate
     FlxG.addChildBelowMouse(realStats);
     #end
 
-    FlxG.stage.addEventListener(KeyboardEvent.KEY_DOWN, key ->
-    {
+    FlxG.stage.addEventListener(KeyboardEvent.KEY_DOWN, key -> {
       trace(key.charCode);
 
       if (key.charCode == 120) generateBeatStuff();
@@ -154,8 +153,7 @@ class LatencyState extends MusicBeatSubstate
 
   override function beatHit():Bool
   {
-    if (curBeat % 8 == 0) blocks.forEach(blok ->
-    {
+    if (curBeat % 8 == 0) blocks.forEach(blok -> {
       blok.alpha = 0;
     });
 
@@ -172,7 +170,7 @@ class LatencyState extends MusicBeatSubstate
       trace(FlxG.sound.music._channel.position);
      */
 
-    #if debug
+    #if FLX_DEBUG
     funnyStatsGraph.update(FlxG.sound.music.time % 500);
     realStats.update(swagSong.getTimeWithDiff() % 500);
     #end
@@ -248,8 +246,7 @@ class LatencyState extends MusicBeatSubstate
         FlxG.resetState();
     }*/
 
-    noteGrp.forEach(function(daNote:Note)
-    {
+    noteGrp.forEach(function(daNote:Note) {
       daNote.y = (strumLine.y - ((Conductor.songPosition - Conductor.audioOffset) - daNote.data.strumTime) * 0.45);
       daNote.x = strumLine.x + 30;
 
diff --git a/source/funkin/MainMenuState.hx b/source/funkin/MainMenuState.hx
index 85c91db93..e4bdfbe35 100644
--- a/source/funkin/MainMenuState.hx
+++ b/source/funkin/MainMenuState.hx
@@ -21,6 +21,7 @@ import funkin.shaderslmfao.ScreenWipeShader;
 import funkin.ui.AtlasMenuList;
 import funkin.ui.MenuList.MenuItem;
 import funkin.ui.MenuList;
+import funkin.ui.story.StoryMenuState;
 import funkin.ui.OptionsState;
 import funkin.ui.PreferencesMenu;
 import funkin.ui.Prompt;
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index 3a1c65285..60dcfad38 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -103,9 +103,9 @@ class Paths
     return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.$SOUND_EXT';
   }
 
-  inline static public function inst(song:String)
+  inline static public function inst(song:String, ?suffix:String)
   {
-    return 'songs:assets/songs/${song.toLowerCase()}/Inst.$SOUND_EXT';
+    return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.$SOUND_EXT';
   }
 
   inline static public function image(key:String, ?library:String)
diff --git a/source/funkin/StoryMenuState.hx b/source/funkin/StoryMenuState.hx
index d9640d620..89d59de1f 100644
--- a/source/funkin/StoryMenuState.hx
+++ b/source/funkin/StoryMenuState.hx
@@ -122,10 +122,10 @@ class StoryMenuState extends MusicBeatState
 
     persistentUpdate = persistentDraw = true;
 
-    scoreText = new FlxText(10, 10, 0, "SCORE: 49324858", 36);
+    scoreText = new FlxText(10, 10, 0, "SCORE: 49324858");
     scoreText.setFormat("VCR OSD Mono", 32);
 
-    txtWeekTitle = new FlxText(FlxG.width * 0.7, 10, 0, "", 32);
+    txtWeekTitle = new FlxText(FlxG.width * 0.7, 10, 0, "");
     txtWeekTitle.setFormat("VCR OSD Mono", 32, FlxColor.WHITE, RIGHT);
     txtWeekTitle.alpha = 0.7;
 
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
new file mode 100644
index 000000000..0864fddd9
--- /dev/null
+++ b/source/funkin/data/BaseRegistry.hx
@@ -0,0 +1,167 @@
+package funkin.data;
+
+import openfl.Assets;
+import funkin.util.assets.DataAssets;
+import haxe.Constraints.Constructible;
+
+/**
+ * The entry's constructor function must take a single argument, the entry's ID.
+ */
+typedef EntryConstructorFunction = String->Void;
+
+/**
+ * A base type for a Registry, which is an object which handles loading scriptable objects.
+ * 
+ * @param T The type to construct. Must implement `IRegistryEntry`.
+ * @param J The type of the JSON data used when constructing.
+ */
+@:generic
+abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructorFunction>), J>
+{
+  public final registryId:String;
+
+  final dataFilePath:String;
+
+  final entries:Map<String, T>;
+
+  // public abstract static final instance:BaseRegistry<T, J> = new BaseRegistry<>();
+
+  /**
+   * @param registryId A readable ID for this registry, used when logging.
+   * @param dataFilePath The path (relative to `assets/data`) to search for JSON files.
+   */
+  public function new(registryId:String, dataFilePath:String)
+  {
+    this.registryId = registryId;
+    this.dataFilePath = dataFilePath;
+
+    this.entries = new Map<String, T>();
+  }
+
+  public function loadEntries():Void
+  {
+    clearEntries();
+
+    //
+    // SCRIPTED ENTRIES
+    //
+    var scriptedEntryClassNames:Array<String> = getScriptedClassNames();
+    log('Registering ${scriptedEntryClassNames.length} scripted entries...');
+
+    for (entryCls in scriptedEntryClassNames)
+    {
+      var entry:T = createScriptedEntry(entryCls);
+
+      if (entry != null)
+      {
+        log('Successfully created scripted entry (${entryCls} = ${entry.id})');
+        entries.set(entry.id, entry);
+      }
+      else
+      {
+        log('Failed to create scripted entry (${entryCls})');
+      }
+    }
+
+    //
+    // UNSCRIPTED ENTRIES
+    //
+    var entryIdList:Array<String> = DataAssets.listDataFilesInPath('${dataFilePath}/');
+    var unscriptedEntryIds:Array<String> = entryIdList.filter(function(entryId:String):Bool {
+      return !entries.exists(entryId);
+    });
+    log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...');
+    for (entryId in unscriptedEntryIds)
+    {
+      try
+      {
+        var entry:T = createEntry(entryId);
+        if (entry != null)
+        {
+          trace('  Loaded entry data: ${entry}');
+          entries.set(entry.id, entry);
+        }
+      }
+      catch (e:Dynamic)
+      {
+        trace('  Failed to load entry data: ${entryId}');
+        trace(e);
+        continue;
+      }
+    }
+  }
+
+  public function listEntryIds():Array<String>
+  {
+    return entries.keys().array();
+  }
+
+  public function countEntries():Int
+  {
+    return entries.size();
+  }
+
+  public function fetchEntry(id:String):Null<T>
+  {
+    return entries.get(id);
+  }
+
+  public function toString():String
+  {
+    return 'Registry(' + registryId + ', ${countEntries()} entries)';
+  }
+
+  function log(message:String):Void
+  {
+    trace('[' + registryId + '] ' + message);
+  }
+
+  function loadEntryFile(id:String):String
+  {
+    var entryFilePath:String = Paths.json('${dataFilePath}/${id}');
+    var rawJson:String = openfl.Assets.getText(entryFilePath).trim();
+    return rawJson;
+  }
+
+  function clearEntries():Void
+  {
+    for (entry in entries)
+    {
+      entry.destroy();
+    }
+
+    entries.clear();
+  }
+
+  //
+  // FUNCTIONS TO IMPLEMENT
+  //
+
+  /**
+   * Read, parse, and validate the JSON data and produce the corresponding data object.
+   * 
+   * NOTE: Must be implemented on the implementation class annd 
+   */
+  public abstract function parseEntryData(id:String):Null<J>;
+
+  /**
+   * Retrieve the list of scripted class names to load.
+   * @return An array of scripted class names.
+   */
+  abstract function getScriptedClassNames():Array<String>;
+
+  /**
+   * Create an entry from the given ID.
+   * @param id
+   */
+  function createEntry(id:String):Null<T>
+  {
+    return new T(id);
+  }
+
+  /**
+   * Create a entry, attached to a scripted class, from the given class name.
+   * @param clsName 
+   */
+  abstract function createScriptedEntry(clsName:String):Null<T>;
+}
diff --git a/source/funkin/data/IRegistryEntry.hx b/source/funkin/data/IRegistryEntry.hx
new file mode 100644
index 000000000..0fb704b7c
--- /dev/null
+++ b/source/funkin/data/IRegistryEntry.hx
@@ -0,0 +1,19 @@
+package funkin.data;
+
+/**
+ * An interface defining the necessary functions for a registry entry.
+ * A `String->Void` constructor is also mandatory, but enforced elsewhere.
+ * @param T The JSON data type of the registry entry.
+ */
+interface IRegistryEntry<T>
+{
+  public final id:String;
+
+  // public function new(id:String):Void;
+  public function destroy():Void;
+  public function toString():String;
+
+  // Can't make an interface field private I guess.
+  public final _data:T;
+  public function _fetchData(id:String):Null<T>;
+}
diff --git a/source/funkin/data/level/LevelData.hx b/source/funkin/data/level/LevelData.hx
new file mode 100644
index 000000000..0342c3d39
--- /dev/null
+++ b/source/funkin/data/level/LevelData.hx
@@ -0,0 +1,91 @@
+package funkin.data.level;
+
+import funkin.play.AnimationData;
+
+/**
+ * A type definition for the data in a story mode level JSON file.
+ * @see https://lib.haxe.org/p/json2object/
+ */
+typedef LevelData =
+{
+  /**
+   * The version number of the level data schema.
+   * When making changes to the level data format, this should be incremented,
+   * and a migration function should be added to LevelDataParser to handle old versions.
+   */
+  @:default(funkin.data.level.LevelRegistry.LEVEL_DATA_VERSION)
+  var version:String;
+
+  /**
+   * The title of the week, as seen in the top corner.
+   */
+  var name:String;
+
+  /**
+   * The graphic for the level, as seen in the scrolling list.
+   */
+  var titleAsset:String;
+
+  @:default([])
+  var props:Array<LevelPropData>;
+  @:default(["bopeebo"])
+  var songs:Array<String>;
+  @:default("#F9CF51")
+  @:optional
+  var background:String;
+}
+
+typedef LevelPropData =
+{
+  /**
+   * The image to use for the prop. May optionally be a sprite sheet.
+   */
+  var assetPath:String;
+
+  /**
+   * The scale to render the prop at.
+   * @default 1.0
+   */
+  @:default(1.0)
+  @:optional
+  var scale:Float;
+
+  /**
+   * The opacity to render the prop at.
+   * @default 1.0
+   */
+  @:default(1.0)
+  @:optional
+  var alpha:Float;
+
+  /**
+   * If true, the prop is a pixel sprite, and will be rendered without smoothing.
+   */
+  @:default(false)
+  @:optional
+  var isPixel:Bool;
+
+  /**
+   * The frequency to bop at, in beats.
+   * @default 1 = every beat, 2 = every other beat, etc.
+   */
+  @:default(1)
+  @:optional
+  var danceEvery:Int;
+
+  /**
+   * The offset on the position to render the prop at.
+   * @default [0.0, 0.0]
+   */
+  @:default([0, 0])
+  @:optional
+  var offsets:Array<Float>;
+
+  /**
+   * A set of animations to play on the prop.
+   * If default/empty, the prop will be static.
+   */
+  @:default([])
+  @:optional
+  var animations:Array<AnimationData>;
+}
diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx
new file mode 100644
index 000000000..54ed81093
--- /dev/null
+++ b/source/funkin/data/level/LevelRegistry.hx
@@ -0,0 +1,85 @@
+package funkin.data.level;
+
+import funkin.ui.story.Level;
+import funkin.data.level.LevelData;
+import funkin.ui.story.ScriptedLevel;
+
+class LevelRegistry extends BaseRegistry<Level, LevelData>
+{
+  /**
+   * The current version string for the stage data format.
+   * Handle breaking changes by incrementing this value
+   * and adding migration to the `migrateStageData()` function.
+   */
+  public static final LEVEL_DATA_VERSION:String = "1.0.0";
+
+  public static final instance:LevelRegistry = new LevelRegistry();
+
+  public function new()
+  {
+    super('LEVEL', 'levels');
+  }
+
+  /**
+   * Read, parse, and validate the JSON data and produce the corresponding data object.
+   */
+  public function parseEntryData(id:String):Null<LevelData>
+  {
+    // JsonParser does not take type parameters,
+    // otherwise this function would be in BaseRegistry.
+    var parser = new json2object.JsonParser<LevelData>();
+    var jsonStr:String = loadEntryFile(id);
+
+    parser.fromJson(jsonStr);
+
+    if (parser.errors.length > 0)
+    {
+      trace('Failed to parse entry data: ${id}');
+      for (error in parser.errors)
+      {
+        trace(error);
+      }
+      return null;
+    }
+    return parser.value;
+  }
+
+  function createScriptedEntry(clsName:String):Level
+  {
+    return ScriptedLevel.init(clsName, "unknown");
+  }
+
+  function getScriptedClassNames():Array<String>
+  {
+    return ScriptedLevel.listScriptClasses();
+  }
+
+  /**
+   * A list of all the story weeks from the base game, in order.
+   * TODO: Should this be hardcoded?
+   */
+  public function listBaseGameLevelIds():Array<String>
+  {
+    return [
+      "tutorial",
+      "week1",
+      "week2",
+      "week3",
+      "week4",
+      "week5",
+      "week6",
+      "week7",
+      "weekend1"
+    ];
+  }
+
+  /**
+   * A list of all installed story weeks that are not from the base game.
+   */
+  public function listModdedLevelIds():Array<String>
+  {
+    return listEntryIds().filter(function(id:String):Bool {
+      return listBaseGameLevelIds().indexOf(id) == -1;
+    });
+  }
+}
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index 08b070835..a8e4a6109 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -277,6 +277,7 @@ class PolymodHandler
 
     // TODO: Reload event callbacks
 
+    funkin.data.level.LevelRegistry.instance.loadEntries();
     SongDataParser.loadSongCache();
     StageDataParser.loadStageCache();
     CharacterDataParser.loadCharacterCache();
diff --git a/source/funkin/play/AnimationData.hx b/source/funkin/play/AnimationData.hx
index 87bc10102..e512bb757 100644
--- a/source/funkin/play/AnimationData.hx
+++ b/source/funkin/play/AnimationData.hx
@@ -21,36 +21,48 @@ typedef AnimationData =
    * ONLY for use by MultiSparrow characters.
    * @default The assetPath of the parent sprite
    */
+  @:default(null)
+  @:optional
   var assetPath:Null<String>;
 
   /**
    * Offset the character's position by this amount when playing this animation.
    * @default [0, 0]
    */
+  @:default([0, 0])
+  @:optional
   var offsets:Null<Array<Float>>;
 
   /**
    * Whether the animation should loop when it finishes.
    * @default false
    */
+  @:default(false)
+  @:optional
   var looped:Null<Bool>;
 
   /**
    * Whether the animation's sprites should be flipped horizontally.
    * @default false
    */
+  @:default(false)
+  @:optional
   var flipX:Null<Bool>;
 
   /**
    * Whether the animation's sprites should be flipped vertically.
    * @default false
    */
+  @:default(false)
+  @:optional
   var flipY:Null<Bool>;
 
   /**
    * The frame rate of the animation.
    * @default 24
    */
+  @:default(24)
+  @:optional
   var frameRate:Null<Int>;
 
   /**
@@ -59,5 +71,7 @@ typedef AnimationData =
    * @example [0, 1, 2, 3] (use only the first four frames)
    * @default [] (all frames)
    */
+  @:default([])
+  @:optional
   var frameIndices:Null<Array<Int>>;
 }
diff --git a/source/funkin/play/GameOverSubstate.hx b/source/funkin/play/GameOverSubstate.hx
index ddedf257c..3d5470324 100644
--- a/source/funkin/play/GameOverSubstate.hx
+++ b/source/funkin/play/GameOverSubstate.hx
@@ -3,6 +3,7 @@ package funkin.play;
 import flixel.FlxObject;
 import flixel.FlxSprite;
 import flixel.system.FlxSound;
+import funkin.ui.story.StoryMenuState;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
 import funkin.modding.events.ScriptEvent;
@@ -208,11 +209,9 @@ class GameOverSubstate extends MusicBeatSubstate
       boyfriend.playAnimation('deathConfirm' + animationSuffix, true);
 
       // After the animation finishes...
-      new FlxTimer().start(0.7, function(tmr:FlxTimer)
-      {
+      new FlxTimer().start(0.7, function(tmr:FlxTimer) {
         // ...fade out the graphics. Then after that happens...
-        FlxG.camera.fade(FlxColor.BLACK, 2, false, function()
-        {
+        FlxG.camera.fade(FlxColor.BLACK, 2, false, function() {
           // ...close the GameOverSubstate.
           FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true);
           PlayState.needsReset = true;
@@ -276,8 +275,7 @@ class GameOverSubstate extends MusicBeatSubstate
 
     if (PreferencesMenu.getPref('censor-naughty')) randomCensor = [1, 3, 8, 13, 17, 21];
 
-    FlxG.sound.play(Paths.sound('jeffGameover/jeffGameover-' + FlxG.random.int(1, 25, randomCensor)), 1, false, null, true, function()
-    {
+    FlxG.sound.play(Paths.sound('jeffGameover/jeffGameover-' + FlxG.random.int(1, 25, randomCensor)), 1, false, null, true, function() {
       // Once the quote ends, fade in the game over music.
       if (!isEnding && gameOverMusic != null)
       {
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 0d99744a1..afdf48ffd 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -2,6 +2,7 @@ package funkin.play;
 
 import flixel.FlxCamera;
 import flixel.FlxObject;
+import funkin.ui.story.StoryMenuState;
 import flixel.FlxSprite;
 import flixel.FlxState;
 import flixel.FlxSubState;
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 853846414..871cdd713 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -29,11 +29,14 @@ class Song // implements IPlayStateScriptedClass
   final variations:Array<String>;
   final difficulties:Map<String, SongDifficulty>;
 
+  var difficultyIds:Array<String>;
+
   public function new(id:String)
   {
     this.songId = id;
 
     variations = [];
+    difficultyIds = [];
     difficulties = new Map<String, SongDifficulty>();
 
     _metadata = SongDataParser.parseSongMetadata(songId);
@@ -61,6 +64,8 @@ class Song // implements IPlayStateScriptedClass
     {
       for (diffId in metadata.playData.difficulties)
       {
+        difficultyIds.push(diffId);
+
         var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation);
 
         variations.push(metadata.variation);
@@ -136,6 +141,16 @@ class Song // implements IPlayStateScriptedClass
     return difficulties.get(diffId);
   }
 
+  public function listDifficulties():Array<String>
+  {
+    return difficultyIds;
+  }
+
+  public function hasDifficulty(diffId:String):Bool
+  {
+    return difficulties.exists(diffId);
+  }
+
   /**
    * Purge the cached chart data for each difficulty of this song.
    */
@@ -238,7 +253,8 @@ class SongDifficulty
 
   public inline function playInst(volume:Float = 1.0, looped:Bool = false)
   {
-    FlxG.sound.playMusic(Paths.inst(this.song.songId), volume, looped);
+    var suffix:String = variation == null ? null : '-$variation';
+    FlxG.sound.playMusic(Paths.inst(this.song.songId, suffix), volume, looped);
   }
 
   public inline function cacheVocals()
@@ -250,7 +266,7 @@ class SongDifficulty
   {
     // TODO: Implement.
 
-    return [""];
+    return [variation == null ? '' : '-$variation'];
   }
 
   public function buildVocals(charId:String = "bf"):VoicesGroup
diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx
index 60ae32ec1..c5a886ba9 100644
--- a/source/funkin/play/song/SongData.hx
+++ b/source/funkin/play/song/SongData.hx
@@ -143,9 +143,15 @@ class SongDataParser
 
     for (variation in variations)
     {
-      var variationRawJson:String = loadSongMetadataFile(songId, variation);
-      var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationRawJson, '${songId}_${variation}');
-      variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}_${variation}');
+      var variationJsonStr:String = loadSongMetadataFile(songId, variation);
+      var variationJsonData:Dynamic = null;
+      try
+      {
+        variationJsonData = Json.parse(variationJsonStr);
+      }
+      catch (e) {}
+      var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationJsonData, '${songId}-${variation}');
+      variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}-${variation}');
       if (variationSongMetadata != null)
       {
         variationSongMetadata.variation = variation;
diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index 09aa910e0..623660af7 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -256,6 +256,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     var correctName = correctAnimationName(name);
     if (correctName == null) return;
 
+    this.animation.paused = false;
     this.animation.play(correctName, restart, false, 0);
 
     if (ignoreOther)
diff --git a/source/funkin/ui/StickerSubState.hx b/source/funkin/ui/StickerSubState.hx
index b300640a4..9658275e9 100644
--- a/source/funkin/ui/StickerSubState.hx
+++ b/source/funkin/ui/StickerSubState.hx
@@ -4,6 +4,7 @@ import flixel.FlxSprite;
 import haxe.Json;
 import lime.utils.Assets;
 // import flxtyped group
+import funkin.ui.story.StoryMenuState;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.util.FlxTimer;
 import flixel.FlxG;
diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx
new file mode 100644
index 000000000..5d24de312
--- /dev/null
+++ b/source/funkin/ui/story/Level.hx
@@ -0,0 +1,177 @@
+package funkin.ui.story;
+
+import flixel.FlxSprite;
+import flixel.util.FlxColor;
+import funkin.play.song.Song;
+import funkin.data.IRegistryEntry;
+import funkin.data.level.LevelRegistry;
+import funkin.data.level.LevelData;
+
+/**
+ * An object used to retrieve data about a story mode level (also known as "weeks").
+ * Can be scripted to override each function, for custom behavior.
+ */
+class Level implements IRegistryEntry<LevelData>
+{
+  /**
+   * The ID of the story mode level.
+   */
+  public final id:String;
+
+  /**
+   * Level data as parsed from the JSON file.
+   */
+  public final _data:LevelData;
+
+  /**
+   * @param id The ID of the JSON file to parse.
+   */
+  public function new(id:String)
+  {
+    this.id = id;
+    _data = _fetchData(id);
+
+    if (_data == null)
+    {
+      throw 'Could not parse level data for id: $id';
+    }
+  }
+
+  /**
+   * Get the list of songs in this level, as an array of IDs.
+   * @return Array<String>
+   */
+  public function getSongs():Array<String>
+  {
+    return _data.songs;
+  }
+
+  /**
+   * Retrieve the title of the level for display on the menu.
+   */
+  public function getTitle():String
+  {
+    // TODO: Maybe add localization support?
+    return _data.name;
+  }
+
+  public function buildTitleGraphic():FlxSprite
+  {
+    var result = new FlxSprite().loadGraphic(Paths.image(_data.titleAsset));
+
+    return result;
+  }
+
+  /**
+   * Get the list of songs in this level, as an array of names, for display on the menu.
+   * @return Array<String>
+   */
+  public function getSongDisplayNames(difficulty:String):Array<String>
+  {
+    var songList:Array<String> = getSongs() ?? [];
+    var songNameList:Array<String> = songList.map(function(songId) {
+      return funkin.play.song.SongData.SongDataParser.fetchSong(songId) ?.getDifficulty(difficulty) ?.songName ?? 'Unknown';
+    });
+    return songNameList;
+  }
+
+  /**
+   * Whether this level is unlocked. If not, it will be greyed out on the menu and have a lock icon.
+   * TODO: Change this behavior in a later release.
+   */
+  public function isUnlocked():Bool
+  {
+    return true;
+  }
+
+  /**
+   * Whether this level is visible. If not, it will not be shown on the menu at all.
+   */
+  public function isVisible():Bool
+  {
+    return true;
+  }
+
+  public function buildBackground():FlxSprite
+  {
+    if (_data.background.startsWith('#'))
+    {
+      // Color specified
+      var color:FlxColor = FlxColor.fromString(_data.background);
+      return new FlxSprite().makeGraphic(FlxG.width, 400, color);
+    }
+    else
+    {
+      // Image specified
+      return new FlxSprite().loadGraphic(Paths.image(_data.background));
+    }
+  }
+
+  public function getDifficulties():Array<String>
+  {
+    var difficulties:Array<String> = [];
+
+    var songList = getSongs();
+
+    var firstSongId:String = songList[0];
+    var firstSong:Song = funkin.play.song.SongData.SongDataParser.fetchSong(firstSongId);
+
+    if (firstSong != null)
+    {
+      for (difficulty in firstSong.listDifficulties())
+      {
+        difficulties.push(difficulty);
+      }
+    }
+
+    // Filter to only include difficulties that are present in all songs
+    for (songIndex in 1...songList.length)
+    {
+      var songId:String = songList[songIndex];
+      var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
+
+      if (song == null) continue;
+
+      for (difficulty in difficulties)
+      {
+        if (!song.hasDifficulty(difficulty))
+        {
+          difficulties.remove(difficulty);
+        }
+      }
+    }
+
+    if (difficulties.length == 0) difficulties = ['normal'];
+
+    return difficulties;
+  }
+
+  public function buildProps():Array<LevelProp>
+  {
+    var props:Array<LevelProp> = [];
+
+    if (_data.props.length == 0) return props;
+
+    for (propIndex in 0..._data.props.length)
+    {
+      var propData = _data.props[propIndex];
+      var propSprite:LevelProp = LevelProp.build(propData);
+      propSprite.x += FlxG.width * 0.25 * propIndex;
+      props.push(propSprite);
+    }
+
+    return props;
+  }
+
+  public function destroy():Void {}
+
+  public function toString():String
+  {
+    return 'Level($id)';
+  }
+
+  public function _fetchData(id:String):Null<LevelData>
+  {
+    return LevelRegistry.instance.parseEntryData(id);
+  }
+}
diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx
new file mode 100644
index 000000000..a474b363c
--- /dev/null
+++ b/source/funkin/ui/story/LevelProp.hx
@@ -0,0 +1,63 @@
+package funkin.ui.story;
+
+import funkin.play.stage.Bopper;
+import funkin.util.assets.FlxAnimationUtil;
+import funkin.data.level.LevelData;
+
+class LevelProp extends Bopper
+{
+  public function new(danceEvery:Int)
+  {
+    super(danceEvery);
+  }
+
+  public function playConfirm():Void
+  {
+    playAnimation('confirm', true, true);
+  }
+
+  public static function build(propData:LevelPropData):Null<LevelProp>
+  {
+    var isAnimated:Bool = propData.animations.length > 0;
+    var prop:LevelProp = new LevelProp(propData.danceEvery);
+
+    if (isAnimated)
+    {
+      // Initalize sprite frames.
+      // Sparrow atlas only LEL.
+      prop.frames = Paths.getSparrowAtlas(propData.assetPath);
+    }
+    else
+    {
+      // Initalize static sprite.
+      prop.loadGraphic(Paths.image(propData.assetPath));
+
+      // Disables calls to update() for a performance boost.
+      prop.active = false;
+    }
+
+    if (prop.frames == null || prop.frames.numFrames == 0)
+    {
+      trace('ERROR: Could not build texture for level prop (${propData.assetPath}).');
+      return null;
+    }
+
+    var scale:Float = propData.scale * (propData.isPixel ? 6 : 1);
+    prop.scale.set(scale, scale);
+    prop.antialiasing = !propData.isPixel;
+    prop.alpha = propData.alpha;
+    prop.x = propData.offsets[0];
+    prop.y = propData.offsets[1];
+
+    FlxAnimationUtil.addAtlasAnimations(prop, propData.animations);
+    for (propAnim in propData.animations)
+    {
+      prop.setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
+    }
+
+    prop.dance();
+    prop.animation.paused = true;
+
+    return prop;
+  }
+}
diff --git a/source/funkin/ui/story/LevelTitle.hx b/source/funkin/ui/story/LevelTitle.hx
new file mode 100644
index 000000000..e1765d453
--- /dev/null
+++ b/source/funkin/ui/story/LevelTitle.hx
@@ -0,0 +1,90 @@
+package funkin.ui.story;
+
+import flixel.FlxSprite;
+import flixel.graphics.frames.FlxAtlasFrames;
+import flixel.group.FlxSpriteGroup;
+import flixel.util.FlxColor;
+import funkin.CoolUtil;
+
+class LevelTitle extends FlxSpriteGroup
+{
+  static final LOCK_PAD:Int = 4;
+
+  public final level:Level;
+
+  public var targetY:Float;
+  public var isFlashing:Bool = false;
+
+  var title:FlxSprite;
+  var lock:FlxSprite;
+
+  var flashingInt:Int = 0;
+
+  public function new(x:Int, y:Int, level:Level)
+  {
+    super(x, y);
+
+    this.level = level;
+
+    if (this.level == null) throw "Level cannot be null!";
+
+    buildLevelTitle();
+    buildLevelLock();
+  }
+
+  override function get_width():Float
+  {
+    if (length == 0) return 0;
+
+    if (lock.visible)
+    {
+      return title.width + lock.width + LOCK_PAD;
+    }
+    else
+    {
+      return title.width;
+    }
+  }
+
+  // if it runs at 60fps, fake framerate will be 6
+  // if it runs at 144 fps, fake framerate will be like 14, and will update the graphic every 0.016666 * 3 seconds still???
+  // so it runs basically every so many seconds, not dependant on framerate??
+  // I'm still learning how math works thanks whoever is reading this lol
+  var fakeFramerate:Int = Math.round((1 / FlxG.elapsed) / 10);
+
+  public override function update(elapsed:Float):Void
+  {
+    this.y = CoolUtil.coolLerp(y, targetY, 0.17);
+
+    if (isFlashing) flashingInt += 1;
+    if (flashingInt % fakeFramerate >= Math.floor(fakeFramerate / 2)) title.color = 0xFF33ffff;
+    else
+      title.color = FlxColor.WHITE;
+  }
+
+  public function showLock():Void
+  {
+    lock.visible = true;
+    this.x -= (lock.width + LOCK_PAD) / 2;
+  }
+
+  public function hideLock():Void
+  {
+    lock.visible = false;
+    this.x += (lock.width + LOCK_PAD) / 2;
+  }
+
+  function buildLevelTitle():Void
+  {
+    title = level.buildTitleGraphic();
+    add(title);
+  }
+
+  function buildLevelLock():Void
+  {
+    lock = new FlxSprite(0, 0).loadGraphic(Paths.image('storymenu/ui/lock'));
+    lock.x = title.x + title.width + LOCK_PAD;
+    lock.visible = false;
+    add(lock);
+  }
+}
diff --git a/source/funkin/ui/story/ScriptedLevel.hx b/source/funkin/ui/story/ScriptedLevel.hx
new file mode 100644
index 000000000..a9921741c
--- /dev/null
+++ b/source/funkin/ui/story/ScriptedLevel.hx
@@ -0,0 +1,9 @@
+package funkin.ui.story;
+
+/**
+ * A script that can be tied to a Level, which persists across states.
+ * Create a scripted class that extends Level to use this.
+ * This allows you to customize how a specific level appears.
+ */
+@:hscriptClass
+class ScriptedLevel extends funkin.ui.story.Level implements polymod.hscript.HScriptedClass {}
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
new file mode 100644
index 000000000..8a856baf6
--- /dev/null
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -0,0 +1,549 @@
+package funkin.ui.story;
+
+import openfl.utils.Assets;
+import flixel.addons.transition.FlxTransitionableState;
+import flixel.FlxSprite;
+import flixel.group.FlxGroup.FlxTypedGroup;
+import flixel.text.FlxText;
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
+import flixel.util.FlxColor;
+import flixel.util.FlxTimer;
+import funkin.data.level.LevelRegistry;
+import funkin.modding.events.ScriptEvent;
+import funkin.modding.events.ScriptEventDispatcher;
+import funkin.play.PlayState;
+import funkin.play.song.SongData.SongDataParser;
+import funkin.util.Constants;
+
+class StoryMenuState extends MusicBeatState
+{
+  static final DEFAULT_BACKGROUND_COLOR:FlxColor = FlxColor.fromString("#F9CF51");
+  static final BACKGROUND_HEIGHT:Int = 400;
+
+  var currentDifficultyId:String = 'normal';
+
+  var currentLevelId:String = 'tutorial';
+  var currentLevel:Level;
+  var isLevelUnlocked:Bool;
+  var currentLevelTitle:LevelTitle;
+
+  var highScore:Int = 42069420;
+  var highScoreLerp:Int = 12345678;
+
+  var exitingMenu:Bool = false;
+  var selectedLevel:Bool = false;
+
+  var displayingModdedLevels:Bool = false;
+
+  //
+  // RENDER OBJECTS
+  //
+
+  /**
+   * The title of the level at the top.
+   */
+  var levelTitleText:FlxText;
+
+  /**
+   * The score text at the top.
+   */
+  var scoreText:FlxText;
+
+  /**
+   * The list of songs on the left.
+   */
+  var tracklistText:FlxText;
+
+  /**
+   * The titles of the levels in the middle.
+   */
+  var levelTitles:FlxTypedGroup<LevelTitle>;
+
+  /**
+   * The props in the center.
+   */
+  var levelProps:FlxTypedGroup<LevelProp>;
+
+  /**
+   * The background behind the props.
+   */
+  var levelBackground:FlxSprite;
+
+  /**
+   * The left arrow of the difficulty selector.
+   */
+  var leftDifficultyArrow:FlxSprite;
+
+  /**
+   * The right arrow of the difficulty selector.
+   */
+  var rightDifficultyArrow:FlxSprite;
+
+  /**
+   * The text of the difficulty selector.
+   */
+  var difficultySprite:FlxSprite;
+
+  var difficultySprites:Map<String, FlxSprite>;
+
+  var stickerSubState:StickerSubState;
+
+  public function new(?stickers:StickerSubState = null)
+  {
+    super();
+
+    if (stickers != null)
+    {
+      stickerSubState = stickers;
+    }
+  }
+
+  override function create():Void
+  {
+    super.create();
+
+    difficultySprites = new Map<String, FlxSprite>();
+
+    transIn = FlxTransitionableState.defaultTransIn;
+    transOut = FlxTransitionableState.defaultTransOut;
+
+    if (!FlxG.sound.music.playing)
+    {
+      FlxG.sound.playMusic(Paths.music('freakyMenu'));
+      FlxG.sound.music.fadeIn(4, 0, 0.7);
+      Conductor.forceBPM(Constants.FREAKY_MENU_BPM);
+    }
+
+    if (stickerSubState != null)
+    {
+      this.persistentUpdate = true;
+      this.persistentDraw = true;
+
+      openSubState(stickerSubState);
+      stickerSubState.degenStickers();
+
+      // resetSubState();
+    }
+
+    persistentUpdate = persistentDraw = true;
+
+    updateData();
+
+    // Explicitly define the background color.
+    this.bgColor = FlxColor.BLACK;
+
+    levelTitles = new FlxTypedGroup<LevelTitle>();
+    add(levelTitles);
+
+    updateBackground();
+
+    levelProps = new FlxTypedGroup<LevelProp>();
+    levelProps.zIndex = 1000;
+    add(levelProps);
+
+    updateProps();
+
+    scoreText = new FlxText(10, 10, 0, 'HIGH SCORE: 42069420');
+    scoreText.setFormat("VCR OSD Mono", 32);
+    add(scoreText);
+
+    tracklistText = new FlxText(FlxG.width * 0.05, levelBackground.x + levelBackground.height + 100, 0, "Tracks", 32);
+    tracklistText.setFormat("VCR OSD Mono", 32);
+    tracklistText.alignment = CENTER;
+    tracklistText.color = 0xFFe55777;
+    add(tracklistText);
+
+    levelTitleText = new FlxText(FlxG.width * 0.7, 10, 0, 'LEVEL 1');
+    levelTitleText.setFormat("VCR OSD Mono", 32, FlxColor.WHITE, RIGHT);
+    levelTitleText.alpha = 0.7;
+    add(levelTitleText);
+
+    buildLevelTitles();
+
+    leftDifficultyArrow = new FlxSprite(levelTitles.members[0].x + levelTitles.members[0].width + 10, levelTitles.members[0].y + 10);
+    leftDifficultyArrow.frames = Paths.getSparrowAtlas('storymenu/ui/arrows');
+    leftDifficultyArrow.animation.addByPrefix('idle', 'leftIdle0');
+    leftDifficultyArrow.animation.addByPrefix('press', 'leftConfirm0');
+    leftDifficultyArrow.animation.play('idle');
+    add(leftDifficultyArrow);
+
+    buildDifficultySprite();
+
+    rightDifficultyArrow = new FlxSprite(difficultySprite.x + difficultySprite.width + 10, leftDifficultyArrow.y);
+    rightDifficultyArrow.frames = leftDifficultyArrow.frames;
+    rightDifficultyArrow.animation.addByPrefix('idle', 'rightIdle0');
+    rightDifficultyArrow.animation.addByPrefix('press', 'rightConfirm0');
+    rightDifficultyArrow.animation.play('idle');
+    add(rightDifficultyArrow);
+
+    add(difficultySprite);
+
+    updateText();
+    changeDifficulty();
+    changeLevel();
+    refresh();
+
+    #if discord_rpc
+    // Updating Discord Rich Presence
+    DiscordClient.changePresence("In the Menus", null);
+    #end
+  }
+
+  function updateData():Void
+  {
+    currentLevel = LevelRegistry.instance.fetchEntry(currentLevelId);
+    isLevelUnlocked = currentLevel == null ? false : currentLevel.isUnlocked();
+  }
+
+  function buildDifficultySprite():Void
+  {
+    remove(difficultySprite);
+    difficultySprite = difficultySprites.get(currentDifficultyId);
+    if (difficultySprite == null)
+    {
+      difficultySprite = new FlxSprite(leftDifficultyArrow.x + leftDifficultyArrow.width + 10, leftDifficultyArrow.y);
+
+      if (Assets.exists(Paths.file('images/storymenu/difficulties/${currentDifficultyId}.xml')))
+      {
+        difficultySprite.frames = Paths.getSparrowAtlas('storymenu/difficulties/${currentDifficultyId}');
+        difficultySprite.animation.addByPrefix('idle', 'idle0', 24, true);
+        difficultySprite.animation.play('idle');
+      }
+      else
+      {
+        difficultySprite.loadGraphic(Paths.image('storymenu/difficulties/${currentDifficultyId}'));
+      }
+
+      difficultySprites.set(currentDifficultyId, difficultySprite);
+
+      difficultySprite.x += (difficultySprites.get('normal').width - difficultySprite.width) / 2;
+    }
+    difficultySprite.alpha = 0;
+
+    difficultySprite.y = leftDifficultyArrow.y - 15;
+    var targetY:Float = leftDifficultyArrow.y + 10;
+    targetY -= (difficultySprite.height - difficultySprites.get('normal').height) / 2;
+    FlxTween.tween(difficultySprite, {y: targetY, alpha: 1}, 0.07);
+
+    add(difficultySprite);
+  }
+
+  function buildLevelTitles():Void
+  {
+    levelTitles.clear();
+
+    var levelIds:Array<String> = displayingModdedLevels ? LevelRegistry.instance.listModdedLevelIds() : LevelRegistry.instance.listBaseGameLevelIds();
+    if (levelIds.length == 0) levelIds = ['tutorial']; // Make sure there's at least one level to display.
+
+    for (levelIndex in 0...levelIds.length)
+    {
+      var levelId:String = levelIds[levelIndex];
+      var level:Level = LevelRegistry.instance.fetchEntry(levelId);
+      if (level == null) continue;
+
+      var levelTitleItem:LevelTitle = new LevelTitle(0, Std.int(levelBackground.y + levelBackground.height + 10), level);
+      levelTitleItem.targetY = ((levelTitleItem.height + 20) * levelIndex);
+      levelTitleItem.screenCenter(X);
+      levelTitles.add(levelTitleItem);
+    }
+  }
+
+  function switchMode(moddedLevels:Bool):Void
+  {
+    displayingModdedLevels = moddedLevels;
+    buildLevelTitles();
+
+    changeLevel(0);
+    changeDifficulty(0);
+  }
+
+  override function update(elapsed:Float)
+  {
+    Conductor.update();
+
+    highScoreLerp = Std.int(CoolUtil.coolLerp(highScoreLerp, highScore, 0.5));
+
+    scoreText.text = 'LEVEL SCORE: ${Math.round(highScoreLerp)}';
+
+    levelTitleText.text = currentLevel.getTitle();
+    levelTitleText.x = FlxG.width - (levelTitleText.width + 10); // Right align.
+
+    handleKeyPresses();
+
+    super.update(elapsed);
+  }
+
+  function handleKeyPresses():Void
+  {
+    if (!exitingMenu)
+    {
+      if (!selectedLevel)
+      {
+        if (controls.UI_UP_P)
+        {
+          changeLevel(-1);
+          changeDifficulty(0);
+        }
+
+        if (controls.UI_DOWN_P)
+        {
+          changeLevel(1);
+          changeDifficulty(0);
+        }
+
+        if (controls.UI_RIGHT)
+        {
+          rightDifficultyArrow.animation.play('press');
+        }
+        else
+        {
+          rightDifficultyArrow.animation.play('idle');
+        }
+
+        if (controls.UI_LEFT)
+        {
+          leftDifficultyArrow.animation.play('press');
+        }
+        else
+        {
+          leftDifficultyArrow.animation.play('idle');
+        }
+
+        if (controls.UI_RIGHT_P)
+        {
+          changeDifficulty(1);
+        }
+
+        if (controls.UI_LEFT_P)
+        {
+          changeDifficulty(-1);
+        }
+
+        if (FlxG.keys.justPressed.TAB)
+        {
+          switchMode(!displayingModdedLevels);
+        }
+      }
+
+      if (controls.ACCEPT)
+      {
+        selectLevel();
+      }
+    }
+
+    if (controls.BACK && !exitingMenu && !selectedLevel)
+    {
+      FlxG.sound.play(Paths.sound('cancelMenu'));
+      exitingMenu = true;
+      FlxG.switchState(new MainMenuState());
+    }
+  }
+
+  /**
+   * Changes the selected level.
+   * @param change +1 (down), -1 (up)
+   */
+  function changeLevel(change:Int = 0):Void
+  {
+    var levelList:Array<String> = displayingModdedLevels ? LevelRegistry.instance.listModdedLevelIds() : LevelRegistry.instance.listBaseGameLevelIds();
+    if (levelList.length == 0) levelList = ['tutorial'];
+
+    var currentIndex:Int = levelList.indexOf(currentLevelId);
+
+    currentIndex += change;
+
+    // Wrap around
+    if (currentIndex < 0) currentIndex = levelList.length - 1;
+    if (currentIndex >= levelList.length) currentIndex = 0;
+
+    currentLevelId = levelList[currentIndex];
+
+    updateData();
+
+    for (index in 0...levelTitles.members.length)
+    {
+      var item:LevelTitle = levelTitles.members[index];
+
+      item.targetY = (index - currentIndex) * 120 + 480;
+
+      if (index == currentIndex)
+      {
+        currentLevelTitle = item;
+        item.alpha = 1.0;
+      }
+      else if (index > currentIndex)
+      {
+        item.alpha = 0.6;
+      }
+      else
+      {
+        item.alpha = 0.0;
+      }
+    }
+
+    updateText();
+    updateBackground();
+    updateProps();
+    refresh();
+  }
+
+  /**
+   * Changes the selected difficulty.
+   * @param change +1 (right) to increase difficulty, -1 (left) to decrease difficulty 
+   */
+  function changeDifficulty(change:Int = 0):Void
+  {
+    var difficultyList:Array<String> = currentLevel.getDifficulties();
+    var currentIndex:Int = difficultyList.indexOf(currentDifficultyId);
+
+    currentIndex += change;
+
+    // Wrap around
+    if (currentIndex < 0) currentIndex = difficultyList.length - 1;
+    if (currentIndex >= difficultyList.length) currentIndex = 0;
+
+    var hasChanged:Bool = currentDifficultyId != difficultyList[currentIndex];
+    currentDifficultyId = difficultyList[currentIndex];
+
+    if (difficultyList.length <= 1)
+    {
+      leftDifficultyArrow.visible = false;
+      rightDifficultyArrow.visible = false;
+    }
+    else
+    {
+      leftDifficultyArrow.visible = true;
+      rightDifficultyArrow.visible = true;
+    }
+
+    if (hasChanged)
+    {
+      buildDifficultySprite();
+      funnyMusicThing();
+    }
+  }
+
+  final FADE_OUT_TIME:Float = 1.5;
+
+  function funnyMusicThing():Void
+  {
+    if (currentDifficultyId == "nightmare")
+    {
+      FlxG.sound.music.fadeOut(FADE_OUT_TIME, 0.0);
+    }
+    else
+    {
+      FlxG.sound.music.fadeOut(FADE_OUT_TIME, 1.0);
+    }
+  }
+
+  override function dispatchEvent(event:ScriptEvent):Void
+  {
+    // super.dispatchEvent(event) dispatches event to module scripts.
+    super.dispatchEvent(event);
+
+    if ((levelProps?.length ?? 0) > 0)
+    {
+      // Dispatch event to props.
+      for (prop in levelProps)
+      {
+        ScriptEventDispatcher.callEvent(prop, event);
+      }
+    }
+  }
+
+  function selectLevel()
+  {
+    if (!currentLevel.isUnlocked())
+    {
+      FlxG.sound.play(Paths.sound('cancelMenu'));
+      return;
+    }
+
+    if (selectedLevel) return;
+
+    selectedLevel = true;
+
+    FlxG.sound.play(Paths.sound('confirmMenu'));
+
+    currentLevelTitle.isFlashing = true;
+
+    for (prop in levelProps.members)
+    {
+      prop.playConfirm();
+    }
+
+    PlayState.storyPlaylist = currentLevel.getSongs();
+    PlayState.isStoryMode = true;
+
+    PlayState.currentSong = SongLoad.loadFromJson(PlayState.storyPlaylist[0].toLowerCase(), PlayState.storyPlaylist[0].toLowerCase());
+    PlayState.currentSong_NEW = SongDataParser.fetchSong(PlayState.storyPlaylist[0].toLowerCase());
+
+    // TODO: Fix this.
+    PlayState.storyWeek = 0;
+    PlayState.campaignScore = 0;
+
+    // TODO: Fix this.
+    PlayState.storyDifficulty = 0;
+    PlayState.storyDifficulty_NEW = currentDifficultyId;
+
+    SongLoad.curDiff = PlayState.storyDifficulty_NEW;
+
+    new FlxTimer().start(1, function(tmr:FlxTimer) {
+      LoadingState.loadAndSwitchState(new PlayState(), true);
+    });
+  }
+
+  function updateBackground():Void
+  {
+    if (levelBackground != null)
+    {
+      var oldBackground:FlxSprite = levelBackground;
+
+      FlxTween.tween(oldBackground, {alpha: 0.0}, 0.6,
+        {
+          ease: FlxEase.linear,
+          onComplete: function(_) {
+            remove(oldBackground);
+          }
+        });
+    }
+
+    levelBackground = currentLevel.buildBackground();
+    levelBackground.x = 0;
+    levelBackground.y = 56;
+    levelBackground.alpha = 0.0;
+    levelBackground.zIndex = 100;
+    add(levelBackground);
+
+    FlxTween.tween(levelBackground, {alpha: 1.0}, 0.6,
+      {
+        ease: FlxEase.linear
+      });
+  }
+
+  function updateProps():Void
+  {
+    levelProps.clear();
+    for (prop in currentLevel.buildProps())
+    {
+      prop.zIndex = 1000;
+      levelProps.add(prop);
+    }
+
+    refresh();
+  }
+
+  function updateText():Void
+  {
+    tracklistText.text = 'TRACKS\n\n';
+    tracklistText.text += currentLevel.getSongDisplayNames(currentDifficultyId).join('\n');
+
+    tracklistText.screenCenter(X);
+    tracklistText.x -= FlxG.width * 0.35;
+
+    // TODO: Fix this.
+    highScore = Highscore.getWeekScore(0, 0);
+  }
+}
diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx
index daedb4aab..430b7bc81 100644
--- a/source/funkin/util/tools/MapTools.hx
+++ b/source/funkin/util/tools/MapTools.hx
@@ -9,13 +9,27 @@ package funkin.util.tools;
  */
 class MapTools
 {
+  /**
+   * Return the quantity of keys in the map.
+   */
+  public static function size<K, T>(map:Map<K, T>):Int
+  {
+    return map.keys().array().length;
+  }
+
+  /**
+   * Return a list of values from the map, as an array.
+   */
   public static function values<K, T>(map:Map<K, T>):Array<T>
   {
     return [for (i in map.iterator()) i];
   }
 
+  /**
+   * Return a list of keys from the map (as an array, rather than an iterator).
+   */
   public static function keyValues<K, T>(map:Map<K, T>):Array<K>
   {
-    return [for (i in map.keys()) i];
+    return map.keys().array();
   }
 }