From 6d7913786e3f40f6224361e709b77fb991f4138f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 22 Jan 2023 19:55:30 -0500
Subject: [PATCH] Working events in chart editor

---
 Project.xml                                   |   15 +-
 hxformat.json                                 |    5 +-
 source/funkin/Conductor.hx                    |  476 +-
 source/funkin/FreeplayState.hx                | 1673 ++---
 source/funkin/InitState.hx                    |  311 +-
 source/funkin/freeplayStuff/DJBoyfriend.hx    |  292 +-
 source/funkin/import.hx                       |    3 +-
 source/funkin/modding/IScriptedClass.hx       |   15 +
 source/funkin/modding/PolymodErrorHandler.hx  |  140 +-
 source/funkin/modding/PolymodHandler.hx       |  449 +-
 source/funkin/modding/events/ScriptEvent.hx   |  847 +--
 source/funkin/modding/module/Module.hx        |  148 +-
 source/funkin/modding/module/ModuleHandler.hx |  227 +-
 source/funkin/noteStuff/NoteUtil.hx           |  128 +-
 source/funkin/play/PlayState.hx               | 5215 +++++++-------
 source/funkin/play/character/CharacterData.hx |  992 +--
 .../play/character/MultiSparrowCharacter.hx   |  342 +-
 .../funkin/play/character/PackerCharacter.hx  |   98 +-
 .../funkin/play/character/SparrowCharacter.hx |   98 +-
 .../funkin/play/event/FocusCameraSongEvent.hx |  142 +
 .../play/event/PlayAnimationSongEvent.hx      |  111 +
 source/funkin/play/event/ScriptedSongEvent.hx |    9 +
 source/funkin/play/event/SongEvent.hx         |  497 +-
 source/funkin/play/song/Song.hx               |  376 +-
 source/funkin/play/song/SongData.hx           | 1336 ++--
 source/funkin/play/song/SongDataUtils.hx      |   87 +-
 source/funkin/play/song/SongMigrator.hx       |  126 +-
 source/funkin/play/stage/Bopper.hx            |  743 +-
 source/funkin/play/stage/Stage.hx             | 1205 ++--
 source/funkin/play/stage/StageData.hx         |  852 +--
 .../ui/debug/charting/ChartEditorCommand.hx   |  498 +-
 .../debug/charting/ChartEditorEventSprite.hx  |  101 +
 .../debug/charting/ChartEditorNoteSprite.hx   |    3 +-
 .../ui/debug/charting/ChartEditorState.hx     | 6377 +++++++++--------
 .../charting/ChartEditorToolboxHandler.hx     |  977 +--
 .../funkin/ui/haxeui/components/Notifbar.hx   |  113 +
 .../ui/stageBuildShit/StageOffsetSubstate.hx  |    6 -
 source/funkin/util/WindowUtil.hx              |   71 +-
 source/funkin/util/assets/DataAssets.hx       |    2 +-
 source/funkin/util/macro/ClassMacro.hx        |  204 +
 source/funkin/util/macro/CompiledClassList.hx |   69 +
 source/funkin/util/macro/GitCommit.hx         |   98 +-
 source/funkin/util/macro/MacroUtil.hx         |  175 +-
 source/funkin/util/tools/MapTools.hx          |   16 +
 44 files changed, 13661 insertions(+), 12007 deletions(-)
 create mode 100644 source/funkin/play/event/FocusCameraSongEvent.hx
 create mode 100644 source/funkin/play/event/PlayAnimationSongEvent.hx
 create mode 100644 source/funkin/play/event/ScriptedSongEvent.hx
 create mode 100644 source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
 create mode 100644 source/funkin/ui/haxeui/components/Notifbar.hx
 create mode 100644 source/funkin/util/macro/ClassMacro.hx
 create mode 100644 source/funkin/util/macro/CompiledClassList.hx
 create mode 100644 source/funkin/util/tools/MapTools.hx

diff --git a/Project.xml b/Project.xml
index b8f5a0c88..a251ccdbf 100644
--- a/Project.xml
+++ b/Project.xml
@@ -151,16 +151,23 @@
 
 	<!-- HScript relies heavily on Reflection, which means we can't use DCE. -->
 	<haxeflag name="-dce no" />
+	
+	<!-- Ensure all Funkin' classes are available at runtime. -->
 	<haxeflag name="--macro" value="include('funkin')" />
+	
 	<!-- Ensure all UI components are available at runtime. -->
   <haxeflag name="--macro" value="include('haxe.ui.components')" />
   <haxeflag name="--macro" value="include('haxe.ui.containers')" />
-
-	<!-- Ensure all UI components are available at runtime. -->
-	<haxeflag name="--macro" value="include('haxe.ui.components')" />
-	<haxeflag name="--macro" value="include('haxe.ui.containers')" />
 	<haxeflag name="--macro" value="include('haxe.ui.containers.menus')" />
 
+	<!--
+		Ensure additional class packages are available at runtime (some only really used by scripts).
+		Ignore packages we can't include.
+	-->
+	<haxeflag name="--macro" value="include('flixel', true, [ 'flixel.addons.editors.spine.*', 'flixel.addons.nape.*', 'flixel.system.macros.*' ])" />
+
+
+
 	<!-- Necessary to provide stack traces for HScript. -->
 	<haxedef name="hscriptPos" />
 	<haxedef name="HXCPP_CHECK_POINTER" />
diff --git a/hxformat.json b/hxformat.json
index 3eeb6de92..2a7775dda 100644
--- a/hxformat.json
+++ b/hxformat.json
@@ -2,11 +2,14 @@
 	"lineEnds": {
 		"leftCurly": "both",
 		"rightCurly": "both",
-		"emptyCurly": "break",
+		"emptyCurly": "noBreak",
 		"objectLiteralCurly": {
 			"leftCurly": "after"
 		}
 	},
+	"indentation": {
+		"character": "  "
+	},
 	"sameLine": {
 		"ifElse": "next",
 		"doWhile": "next",
diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx
index 425ce25ae..8fb057ae9 100644
--- a/source/funkin/Conductor.hx
+++ b/source/funkin/Conductor.hx
@@ -7,297 +7,295 @@ import funkin.play.song.SongData.SongTimeChange;
 
 typedef BPMChangeEvent =
 {
-	var stepTime:Int;
-	var songTime:Float;
-	var bpm:Float;
+  var stepTime:Int;
+  var songTime:Float;
+  var bpm:Float;
 }
 
 class Conductor
 {
-	/**
-	 * The list of time changes in the song.
-	 * There should be at least one time change (at the beginning of the song) to define the BPM.
-	 */
-	private static var timeChanges:Array<SongTimeChange> = [];
+  /**
+   * The list of time changes in the song.
+   * There should be at least one time change (at the beginning of the song) to define the BPM.
+   */
+  private static var timeChanges:Array<SongTimeChange> = [];
 
-	/**
-	 * The current time change.
-	 */
-	private static var currentTimeChange:SongTimeChange;
+  /**
+   * The current time change.
+   */
+  private static var currentTimeChange:SongTimeChange;
 
-	/**
-	 * The current position in the song in milliseconds.
-	 * Updated every frame based on the audio position.
-	 */
-	public static var songPosition:Float;
+  /**
+   * The current position in the song in milliseconds.
+   * Updated every frame based on the audio position.
+   */
+  public static var songPosition:Float;
 
-	/**
-	 * Beats per minute of the current song at the current time.
-	 */
-	public static var bpm(get, null):Float;
+  /**
+   * Beats per minute of the current song at the current time.
+   */
+  public static var bpm(get, null):Float;
 
-	static function get_bpm():Float
-	{
-		if (bpmOverride != null)
-			return bpmOverride;
+  static function get_bpm():Float
+  {
+    if (bpmOverride != null)
+      return bpmOverride;
 
-		if (currentTimeChange == null)
-			return 100;
+    if (currentTimeChange == null)
+      return 100;
 
-		return currentTimeChange.bpm;
-	}
+    return currentTimeChange.bpm;
+  }
 
-	static var bpmOverride:Null<Float> = null;
+  static var bpmOverride:Null<Float> = null;
 
-	// OLD, replaced with timeChanges.
-	public static var bpmChangeMap:Array<BPMChangeEvent> = [];
+  // OLD, replaced with timeChanges.
+  public static var bpmChangeMap:Array<BPMChangeEvent> = [];
 
-	/**
-	 * Duration of a beat in millisecond. Calculated based on bpm.
-	 */
-	public static var crochet(get, null):Float;
+  /**
+   * Duration of a beat in millisecond. Calculated based on bpm.
+   */
+  public static var crochet(get, null):Float;
 
-	static function get_crochet():Float
-	{
-		return ((60 / bpm) * 1000);
-	}
+  static function get_crochet():Float
+  {
+    return ((60 / bpm) * 1000);
+  }
 
-	/**
-	 * Duration of a step (quarter) in milliseconds. Calculated based on bpm.
-	 */
-	public static var stepCrochet(get, null):Float;
+  /**
+   * Duration of a step (quarter) in milliseconds. Calculated based on bpm.
+   */
+  public static var stepCrochet(get, null):Float;
 
-	static function get_stepCrochet():Float
-	{
-		return crochet / timeSignatureNumerator;
-	}
+  static function get_stepCrochet():Float
+  {
+    return crochet / timeSignatureNumerator;
+  }
 
-	public static var timeSignatureNumerator(get, null):Int;
+  public static var timeSignatureNumerator(get, null):Int;
 
-	static function get_timeSignatureNumerator():Int
-	{
-		if (currentTimeChange == null)
-			return 4;
+  static function get_timeSignatureNumerator():Int
+  {
+    if (currentTimeChange == null)
+      return 4;
 
-		return currentTimeChange.timeSignatureNum;
-	}
+    return currentTimeChange.timeSignatureNum;
+  }
 
-	public static var timeSignatureDenominator(get, null):Int;
+  public static var timeSignatureDenominator(get, null):Int;
 
-	static function get_timeSignatureDenominator():Int
-	{
-		if (currentTimeChange == null)
-			return 4;
+  static function get_timeSignatureDenominator():Int
+  {
+    if (currentTimeChange == null)
+      return 4;
 
-		return currentTimeChange.timeSignatureDen;
-	}
+    return currentTimeChange.timeSignatureDen;
+  }
 
-	/**
-	 * Current position in the song, in beats.
-	**/
-	public static var currentBeat(default, null):Int;
+  /**
+   * Current position in the song, in beats.
+  **/
+  public static var currentBeat(default, null):Int;
 
-	/**
-	 * Current position in the song, in steps.
-	 */
-	public static var currentStep(default, null):Int;
+  /**
+   * Current position in the song, in steps.
+   */
+  public static var currentStep(default, null):Int;
 
-	/**
-	 * Current position in the song, in steps and fractions of a step.
-	 */
-	public static var currentStepTime(default, null):Float;
+  /**
+   * Current position in the song, in steps and fractions of a step.
+   */
+  public static var currentStepTime(default, null):Float;
 
-	public static var beatHit(default, null):FlxSignal = new FlxSignal();
-	public static var stepHit(default, null):FlxSignal = new FlxSignal();
+  public static var beatHit(default, null):FlxSignal = new FlxSignal();
+  public static var stepHit(default, null):FlxSignal = new FlxSignal();
 
-	public static var lastSongPos:Float;
-	public static var visualOffset:Float = 0;
-	public static var audioOffset:Float = 0;
-	public static var offset:Float = 0;
+  public static var lastSongPos:Float;
+  public static var visualOffset:Float = 0;
+  public static var audioOffset:Float = 0;
+  public static var offset:Float = 0;
 
-	// TODO: Add code to update this.
-	public static var beatsPerMeasure(get, null):Int;
+  // TODO: Add code to update this.
+  public static var beatsPerMeasure(get, null):Int;
 
-	static function get_beatsPerMeasure():Int
-	{
-		return timeSignatureNumerator;
-	}
+  static function get_beatsPerMeasure():Int
+  {
+    return timeSignatureNumerator;
+  }
 
-	public static var stepsPerMeasure(get, null):Int;
+  public static var stepsPerMeasure(get, null):Int;
 
-	static function get_stepsPerMeasure():Int
-	{
-		// Is this always x4?
-		return timeSignatureNumerator * 4;
-	}
+  static function get_stepsPerMeasure():Int
+  {
+    // Is this always x4?
+    return timeSignatureNumerator * 4;
+  }
 
-	private function new()
-	{
-	}
+  private function new() {}
 
-	public static function getLastBPMChange()
-	{
-		var lastChange:BPMChangeEvent = {
-			stepTime: 0,
-			songTime: 0,
-			bpm: 0
-		}
-		for (i in 0...Conductor.bpmChangeMap.length)
-		{
-			if (Conductor.songPosition >= Conductor.bpmChangeMap[i].songTime)
-				lastChange = Conductor.bpmChangeMap[i];
+  public static function getLastBPMChange()
+  {
+    var lastChange:BPMChangeEvent = {
+      stepTime: 0,
+      songTime: 0,
+      bpm: 0
+    }
+    for (i in 0...Conductor.bpmChangeMap.length)
+    {
+      if (Conductor.songPosition >= Conductor.bpmChangeMap[i].songTime)
+        lastChange = Conductor.bpmChangeMap[i];
 
-			if (Conductor.songPosition < Conductor.bpmChangeMap[i].songTime)
-				break;
-		}
-		return lastChange;
-	}
+      if (Conductor.songPosition < Conductor.bpmChangeMap[i].songTime)
+        break;
+    }
+    return lastChange;
+  }
 
-	/**
-	 * Forcibly defines the current BPM of the song.
-	 * Useful for things like the chart editor that need to manipulate BPM in real time.
-	 * 
-	 * Set to null to reset to the BPM defined by the timeChanges.
-	 * 
-	 * WARNING: Avoid this for things like setting the BPM of the title screen music,
-	 * you should have a metadata file for it instead.
-	 */
-	public static function forceBPM(?bpm:Float = null)
-	{
-		if (bpm != null)
-			trace('[CONDUCTOR] Forcing BPM to ' + bpm);
-		else
-			trace('[CONDUCTOR] Resetting BPM to default');
-		Conductor.bpmOverride = bpm;
-	}
+  /**
+   * Forcibly defines the current BPM of the song.
+   * Useful for things like the chart editor that need to manipulate BPM in real time.
+   * 
+   * Set to null to reset to the BPM defined by the timeChanges.
+   * 
+   * WARNING: Avoid this for things like setting the BPM of the title screen music,
+   * you should have a metadata file for it instead.
+   */
+  public static function forceBPM(?bpm:Float = null)
+  {
+    if (bpm != null)
+      trace('[CONDUCTOR] Forcing BPM to ' + bpm);
+    else
+      trace('[CONDUCTOR] Resetting BPM to default');
+    Conductor.bpmOverride = bpm;
+  }
 
-	/**
-	 * Update the conductor with the current song position.
-	 * BPM, current step, etc. will be re-calculated based on the song position.
-	 * 
-	 * @param	songPosition The current position in the song in milliseconds.
-	 *        Leave blank to use the FlxG.sound.music position.
-	 */
-	public static function update(songPosition:Float = null)
-	{
-		if (songPosition == null)
-			songPosition = (FlxG.sound.music != null) ? (FlxG.sound.music.time + Conductor.offset) : 0;
+  /**
+   * Update the conductor with the current song position.
+   * BPM, current step, etc. will be re-calculated based on the song position.
+   * 
+   * @param	songPosition The current position in the song in milliseconds.
+   *        Leave blank to use the FlxG.sound.music position.
+   */
+  public static function update(songPosition:Float = null)
+  {
+    if (songPosition == null)
+      songPosition = (FlxG.sound.music != null) ? FlxG.sound.music.time + Conductor.offset : 0.0;
 
-		var oldBeat = currentBeat;
-		var oldStep = currentStep;
+    var oldBeat = currentBeat;
+    var oldStep = currentStep;
 
-		Conductor.songPosition = songPosition;
-		// Conductor.bpm = Conductor.getLastBPMChange().bpm;
+    Conductor.songPosition = songPosition;
+    // Conductor.bpm = Conductor.getLastBPMChange().bpm;
 
-		currentTimeChange = timeChanges[0];
-		for (i in 0...timeChanges.length)
-		{
-			if (songPosition >= timeChanges[i].timeStamp)
-				currentTimeChange = timeChanges[i];
+    currentTimeChange = timeChanges[0];
+    for (i in 0...timeChanges.length)
+    {
+      if (songPosition >= timeChanges[i].timeStamp)
+        currentTimeChange = timeChanges[i];
 
-			if (songPosition < timeChanges[i].timeStamp)
-				break;
-		}
+      if (songPosition < timeChanges[i].timeStamp)
+        break;
+    }
 
-		if (currentTimeChange == null && bpmOverride == null)
-		{
-			trace('WARNING: Conductor is broken, timeChanges is empty.');
-		}
-		else if (currentTimeChange != null)
-		{
-			currentStepTime = (currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepCrochet;
-			currentStep = Math.floor(currentStepTime);
-			currentBeat = Math.floor(currentStep / 4);
-		}
-		else
-		{
-			// Assume a constant BPM equal to the forced value.
-			currentStepTime = (songPosition / stepCrochet);
-			currentStep = Math.floor(currentStepTime);
-			currentBeat = Math.floor(currentStep / 4);
-		}
+    if (currentTimeChange == null && bpmOverride == null && FlxG.sound.music != null)
+    {
+      trace('WARNING: Conductor is broken, timeChanges is empty.');
+    }
+    else if (currentTimeChange != null)
+    {
+      currentStepTime = (currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepCrochet;
+      currentStep = Math.floor(currentStepTime);
+      currentBeat = Math.floor(currentStep / 4);
+    }
+    else
+    {
+      // Assume a constant BPM equal to the forced value.
+      currentStepTime = (songPosition / stepCrochet);
+      currentStep = Math.floor(currentStepTime);
+      currentBeat = Math.floor(currentStep / 4);
+    }
 
-		// FlxSignals are really cool.
-		if (currentStep != oldStep)
-			stepHit.dispatch();
+    // FlxSignals are really cool.
+    if (currentStep != oldStep)
+      stepHit.dispatch();
 
-		if (currentBeat != oldBeat)
-			beatHit.dispatch();
-	}
+    if (currentBeat != oldBeat)
+      beatHit.dispatch();
+  }
 
-	@:deprecated // Switch to TimeChanges instead.
-	public static function mapBPMChanges(song:SwagSong)
-	{
-		bpmChangeMap = [];
+  @:deprecated // Switch to TimeChanges instead.
+  public static function mapBPMChanges(song:SwagSong)
+  {
+    bpmChangeMap = [];
 
-		var curBPM:Float = song.bpm;
-		var totalSteps:Int = 0;
-		var totalPos:Float = 0;
-		for (i in 0...SongLoad.getSong().length)
-		{
-			if (SongLoad.getSong()[i].changeBPM && SongLoad.getSong()[i].bpm != curBPM)
-			{
-				curBPM = SongLoad.getSong()[i].bpm;
-				var event:BPMChangeEvent = {
-					stepTime: totalSteps,
-					songTime: totalPos,
-					bpm: curBPM
-				};
-				bpmChangeMap.push(event);
-			}
+    var curBPM:Float = song.bpm;
+    var totalSteps:Int = 0;
+    var totalPos:Float = 0;
+    for (i in 0...SongLoad.getSong().length)
+    {
+      if (SongLoad.getSong()[i].changeBPM && SongLoad.getSong()[i].bpm != curBPM)
+      {
+        curBPM = SongLoad.getSong()[i].bpm;
+        var event:BPMChangeEvent = {
+          stepTime: totalSteps,
+          songTime: totalPos,
+          bpm: curBPM
+        };
+        bpmChangeMap.push(event);
+      }
 
-			var deltaSteps:Int = SongLoad.getSong()[i].lengthInSteps;
-			totalSteps += deltaSteps;
-			totalPos += ((60 / curBPM) * 1000 / 4) * deltaSteps;
-		}
-	}
+      var deltaSteps:Int = SongLoad.getSong()[i].lengthInSteps;
+      totalSteps += deltaSteps;
+      totalPos += ((60 / curBPM) * 1000 / 4) * deltaSteps;
+    }
+  }
 
-	public static function mapTimeChanges(songTimeChanges:Array<SongTimeChange>)
-	{
-		timeChanges = [];
+  public static function mapTimeChanges(songTimeChanges:Array<SongTimeChange>)
+  {
+    timeChanges = [];
 
-		for (currentTimeChange in songTimeChanges)
-		{
-			timeChanges.push(currentTimeChange);
-		}
+    for (currentTimeChange in songTimeChanges)
+    {
+      timeChanges.push(currentTimeChange);
+    }
 
-		trace('Done mapping time changes: ' + timeChanges);
+    trace('Done mapping time changes: ' + timeChanges);
 
-		// Done.
-	}
+    // Done.
+  }
 
-	/**
-	 * Given a time in milliseconds, return a time in steps.
-	 */
-	public static function getTimeInSteps(ms:Float):Int
-	{
-		if (timeChanges.length == 0)
-		{
-			// Assume a constant BPM equal to the forced value.
-			return Math.floor(ms / stepCrochet);
-		}
-		else
-		{
-			var resultStep:Int = 0;
+  /**
+   * Given a time in milliseconds, return a time in steps.
+   */
+  public static function getTimeInSteps(ms:Float):Int
+  {
+    if (timeChanges.length == 0)
+    {
+      // Assume a constant BPM equal to the forced value.
+      return Math.floor(ms / stepCrochet);
+    }
+    else
+    {
+      var resultStep:Int = 0;
 
-			var lastTimeChange:SongTimeChange = timeChanges[0];
-			for (timeChange in timeChanges)
-			{
-				if (ms >= timeChange.timeStamp)
-				{
-					lastTimeChange = timeChange;
-					resultStep = lastTimeChange.beatTime * 4;
-				}
-				else
-				{
-					// This time change is after the requested time.
-					break;
-				}
-			}
+      var lastTimeChange:SongTimeChange = timeChanges[0];
+      for (timeChange in timeChanges)
+      {
+        if (ms >= timeChange.timeStamp)
+        {
+          lastTimeChange = timeChange;
+          resultStep = lastTimeChange.beatTime * 4;
+        }
+        else
+        {
+          // This time change is after the requested time.
+          break;
+        }
+      }
 
-			resultStep += Math.floor((ms - lastTimeChange.timeStamp) / stepCrochet);
+      resultStep += Math.floor((ms - lastTimeChange.timeStamp) / stepCrochet);
 
-			return resultStep;
-		}
-	}
+      return resultStep;
+    }
+  }
 }
diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx
index 60d8f71c6..664613b1c 100644
--- a/source/funkin/FreeplayState.hx
+++ b/source/funkin/FreeplayState.hx
@@ -38,905 +38,906 @@ import lime.utils.Assets;
 
 class FreeplayState extends MusicBeatSubstate
 {
-	var songs:Array<SongMetadata> = [];
+  var songs:Array<SongMetadata> = [];
 
-	// var selector:FlxText;
-	var curSelected:Int = 0;
-	var curDifficulty:Int = 1;
+  // var selector:FlxText;
+  var curSelected:Int = 0;
+  var curDifficulty:Int = 1;
 
-	var fp:FreeplayScore;
-	var txtCompletion:FlxText;
-	var lerpCompletion:Float = 0;
-	var intendedCompletion:Float = 0;
-	var lerpScore:Float = 0;
-	var intendedScore:Int = 0;
+  var fp:FreeplayScore;
+  var txtCompletion:FlxText;
+  var lerpCompletion:Float = 0;
+  var intendedCompletion:Float = 0;
+  var lerpScore:Float = 0;
+  var intendedScore:Int = 0;
 
-	var grpDifficulties:FlxSpriteGroup;
+  var grpDifficulties:FlxSpriteGroup;
 
-	var coolColors:Array<Int> = [
-		0xff9271fd,
-		0xff9271fd,
-		0xff223344,
-		0xFF941653,
-		0xFFfc96d7,
-		0xFFa0d1ff,
-		0xffff78bf,
-		0xfff6b604
-	];
+  var coolColors:Array<Int> = [
+    0xff9271fd,
+    0xff9271fd,
+    0xff223344,
+    0xFF941653,
+    0xFFfc96d7,
+    0xFFa0d1ff,
+    0xffff78bf,
+    0xfff6b604
+  ];
 
-	private var grpSongs:FlxTypedGroup<Alphabet>;
-	private var grpCapsules:FlxTypedGroup<SongMenuItem>;
-	private var curPlaying:Bool = false;
+  private var grpSongs:FlxTypedGroup<Alphabet>;
+  private var grpCapsules:FlxTypedGroup<SongMenuItem>;
+  private var curPlaying:Bool = false;
 
-	private var dj:DJBoyfriend;
+  private var dj:DJBoyfriend;
 
-	private var iconArray:Array<HealthIcon> = [];
+  private var iconArray:Array<HealthIcon> = [];
 
-	var typing:FlxInputText;
+  var typing:FlxInputText;
 
-	override function create()
-	{
-		FlxTransitionableState.skipNextTransIn = true;
+  override function create()
+  {
+    FlxTransitionableState.skipNextTransIn = true;
 
-		#if discord_rpc
-		// Updating Discord Rich Presence
-		DiscordClient.changePresence("In the Menus", null);
-		#end
+    #if discord_rpc
+    // Updating Discord Rich Presence
+    DiscordClient.changePresence("In the Menus", null);
+    #end
 
-		var isDebug:Bool = false;
+    var isDebug:Bool = false;
 
-		#if debug
-		isDebug = true;
-		addSong('Test', 1, 'bf-pixel');
-		addSong('Pyro', 8, 'darnell');
-		#end
+    #if debug
+    isDebug = true;
+    addSong('Test', 1, 'bf-pixel');
+    addSong('Pyro', 8, 'darnell');
+    #end
 
-		var initSonglist = CoolUtil.coolTextFile(Paths.txt('freeplaySonglist'));
+    var initSonglist = CoolUtil.coolTextFile(Paths.txt('freeplaySonglist'));
 
-		for (i in 0...initSonglist.length)
-		{
-			songs.push(new SongMetadata(initSonglist[i], 1, 'gf'));
-		}
+    for (i in 0...initSonglist.length)
+    {
+      songs.push(new SongMetadata(initSonglist[i], 1, 'gf'));
+    }
 
-		if (FlxG.sound.music != null)
-		{
-			if (!FlxG.sound.music.playing)
-				FlxG.sound.playMusic(Paths.music('freakyMenu'));
-		}
+    if (FlxG.sound.music != null)
+    {
+      if (!FlxG.sound.music.playing)
+        FlxG.sound.playMusic(Paths.music('freakyMenu'));
+    }
 
-		if (StoryMenuState.weekUnlocked[2] || isDebug)
-			addWeek(['Bopeebo', 'Fresh', 'Dadbattle'], 1, ['dad']);
+    if (StoryMenuState.weekUnlocked[2] || isDebug)
+      addWeek(['Bopeebo', 'Fresh', 'Dadbattle'], 1, ['dad']);
 
-		if (StoryMenuState.weekUnlocked[2] || isDebug)
-			addWeek(['Spookeez', 'South', 'Monster'], 2, ['spooky', 'spooky', 'monster']);
+    if (StoryMenuState.weekUnlocked[2] || isDebug)
+      addWeek(['Spookeez', 'South', 'Monster'], 2, ['spooky', 'spooky', 'monster']);
 
-		if (StoryMenuState.weekUnlocked[3] || isDebug)
-			addWeek(['Pico', 'Philly', 'Blammed'], 3, ['pico']);
+    if (StoryMenuState.weekUnlocked[3] || isDebug)
+      addWeek(['Pico', 'Philly', 'Blammed'], 3, ['pico']);
 
-		if (StoryMenuState.weekUnlocked[4] || isDebug)
-			addWeek(['Satin-Panties', 'High', 'Milf'], 4, ['mom']);
+    if (StoryMenuState.weekUnlocked[4] || isDebug)
+      addWeek(['Satin-Panties', 'High', 'Milf'], 4, ['mom']);
 
-		if (StoryMenuState.weekUnlocked[5] || isDebug)
-			addWeek(['Cocoa', 'Eggnog', 'Winter-Horrorland'], 5, ['parents-christmas', 'parents-christmas', 'monster-christmas']);
+    if (StoryMenuState.weekUnlocked[5] || isDebug)
+      addWeek(['Cocoa', 'Eggnog', 'Winter-Horrorland'], 5, ['parents-christmas', 'parents-christmas', 'monster-christmas']);
 
-		if (StoryMenuState.weekUnlocked[6] || isDebug)
-			addWeek(['Senpai', 'Roses', 'Thorns'], 6, ['senpai', 'senpai', 'spirit']);
+    if (StoryMenuState.weekUnlocked[6] || isDebug)
+      addWeek(['Senpai', 'Roses', 'Thorns'], 6, ['senpai', 'senpai', 'spirit']);
 
-		if (StoryMenuState.weekUnlocked[7] || isDebug)
-			addWeek(['Ugh', 'Guns', 'Stress'], 7, ['tankman']);
+    if (StoryMenuState.weekUnlocked[7] || isDebug)
+      addWeek(['Ugh', 'Guns', 'Stress'], 7, ['tankman']);
 
-		addWeek(["Darnell", "lit-up", "2hot", "blazin"], 8, ['darnell']);
+    addWeek(["Darnell", "lit-up", "2hot", "blazin"], 8, ['darnell']);
 
-		// LOAD MUSIC
+    // LOAD MUSIC
 
-		// LOAD CHARACTERS
+    // LOAD CHARACTERS
 
-		trace(FlxG.width);
-		trace(FlxG.camera.zoom);
-		trace(FlxG.camera.initialZoom);
-		trace(FlxCamera.defaultZoom);
+    trace(FlxG.width);
+    trace(FlxG.camera.zoom);
+    trace(FlxG.camera.initialZoom);
+    trace(FlxCamera.defaultZoom);
 
-		var pinkBack:FlxSprite = new FlxSprite().loadGraphic(Paths.image('freeplay/pinkBack'));
-		pinkBack.color = 0xFFffd4e9; // sets it to pink!
-		pinkBack.x -= pinkBack.width;
+    var pinkBack:FlxSprite = new FlxSprite().loadGraphic(Paths.image('freeplay/pinkBack'));
+    pinkBack.color = 0xFFffd4e9; // sets it to pink!
+    pinkBack.x -= pinkBack.width;
 
-		FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut});
-		add(pinkBack);
+    FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut});
+    add(pinkBack);
 
-		var orangeBackShit:FlxSprite = new FlxSprite(84, FlxG.height * 0.68).makeGraphic(Std.int(pinkBack.width), 50, 0xFFffd400);
-		add(orangeBackShit);
+    var orangeBackShit:FlxSprite = new FlxSprite(84, FlxG.height * 0.68).makeGraphic(Std.int(pinkBack.width), 50, 0xFFffd400);
+    add(orangeBackShit);
 
-		var alsoOrangeLOL:FlxSprite = new FlxSprite(0, orangeBackShit.y).makeGraphic(100, Std.int(orangeBackShit.height), 0xFFffd400);
-		add(alsoOrangeLOL);
+    var alsoOrangeLOL:FlxSprite = new FlxSprite(0, orangeBackShit.y).makeGraphic(100, Std.int(orangeBackShit.height), 0xFFffd400);
+    add(alsoOrangeLOL);
 
-		FlxSpriteUtil.alphaMaskFlxSprite(orangeBackShit, pinkBack, orangeBackShit);
-		orangeBackShit.visible = false;
-		alsoOrangeLOL.visible = false;
+    FlxSpriteUtil.alphaMaskFlxSprite(orangeBackShit, pinkBack, orangeBackShit);
+    orangeBackShit.visible = false;
+    alsoOrangeLOL.visible = false;
 
-		var grpTxtScrolls:FlxGroup = new FlxGroup();
-		add(grpTxtScrolls);
-		grpTxtScrolls.visible = false;
+    var grpTxtScrolls:FlxGroup = new FlxGroup();
+    add(grpTxtScrolls);
+    grpTxtScrolls.visible = false;
 
-		var moreWays:BGScrollingText = new BGScrollingText(0, 200, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width);
-		moreWays.funnyColor = 0xFFfff383;
-		moreWays.speed = 4;
-		grpTxtScrolls.add(moreWays);
+    var moreWays:BGScrollingText = new BGScrollingText(0, 200, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width);
+    moreWays.funnyColor = 0xFFfff383;
+    moreWays.speed = 4;
+    grpTxtScrolls.add(moreWays);
 
-		var funnyScroll:BGScrollingText = new BGScrollingText(0, 250, "BOYFRIEND", FlxG.width / 2);
-		funnyScroll.funnyColor = 0xFFff9963;
-		funnyScroll.speed = -1;
-		grpTxtScrolls.add(funnyScroll);
+    var funnyScroll:BGScrollingText = new BGScrollingText(0, 250, "BOYFRIEND", FlxG.width / 2);
+    funnyScroll.funnyColor = 0xFFff9963;
+    funnyScroll.speed = -1;
+    grpTxtScrolls.add(funnyScroll);
 
-		var txtNuts:BGScrollingText = new BGScrollingText(0, 300, "PROTECT YO NUTS", FlxG.width / 2);
-		grpTxtScrolls.add(txtNuts);
+    var txtNuts:BGScrollingText = new BGScrollingText(0, 300, "PROTECT YO NUTS", FlxG.width / 2);
+    grpTxtScrolls.add(txtNuts);
 
-		var funnyScroll2:BGScrollingText = new BGScrollingText(0, 340, "BOYFRIEND", FlxG.width / 2);
-		funnyScroll2.funnyColor = 0xFFff9963;
-		funnyScroll2.speed = -1.2;
-		grpTxtScrolls.add(funnyScroll2);
+    var funnyScroll2:BGScrollingText = new BGScrollingText(0, 340, "BOYFRIEND", FlxG.width / 2);
+    funnyScroll2.funnyColor = 0xFFff9963;
+    funnyScroll2.speed = -1.2;
+    grpTxtScrolls.add(funnyScroll2);
 
-		var moreWays2:BGScrollingText = new BGScrollingText(0, 400, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width);
-		moreWays2.funnyColor = 0xFFfff383;
-		moreWays2.speed = 4.4;
-		grpTxtScrolls.add(moreWays2);
-
-		var funnyScroll3:BGScrollingText = new BGScrollingText(0, orangeBackShit.y, "BOYFRIEND", FlxG.width / 2);
-		funnyScroll3.funnyColor = 0xFFff9963;
-		funnyScroll3.speed = -0.8;
-		grpTxtScrolls.add(funnyScroll3);
-
-		dj = new DJBoyfriend(0, -100);
-		add(dj);
-
-		var bgDad:FlxSprite = new FlxSprite(pinkBack.width * 0.75, 0).loadGraphic(Paths.image('freeplay/freeplayBGdad'));
-		bgDad.setGraphicSize(0, FlxG.height);
-		bgDad.updateHitbox();
-		bgDad.shader = new AngleMask();
-		bgDad.visible = false;
-
-		var blackOverlayBullshitLOLXD:FlxSprite = new FlxSprite(FlxG.width).makeGraphic(Std.int(bgDad.width), Std.int(bgDad.height), FlxColor.BLACK);
-		add(blackOverlayBullshitLOLXD); // used to mask the text lol!
-
-		add(bgDad);
-		FlxTween.tween(blackOverlayBullshitLOLXD, {x: pinkBack.width * 0.75}, 1, {ease: FlxEase.quintOut});
-
-		blackOverlayBullshitLOLXD.shader = bgDad.shader;
-
-		grpSongs = new FlxTypedGroup<Alphabet>();
-		add(grpSongs);
-
-		grpCapsules = new FlxTypedGroup<SongMenuItem>();
-		add(grpCapsules);
-
-		grpDifficulties = new FlxSpriteGroup(-300, 80);
-		add(grpDifficulties);
-
-		grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayEasy')));
-		grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayNorm')));
-		grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayHard')));
-
-		grpDifficulties.group.forEach(function(spr)
-		{
-			spr.visible = false;
-		});
-
-		grpDifficulties.group.members[curDifficulty].visible = true;
-
-		var overhangStuff:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, 64, FlxColor.BLACK);
-		overhangStuff.y -= overhangStuff.height;
-		add(overhangStuff);
-		FlxTween.tween(overhangStuff, {y: 0}, 0.3, {ease: FlxEase.quartOut});
-
-		var fnfFreeplay:FlxText = new FlxText(0, 12, 0, "FREEPLAY", 48);
-		fnfFreeplay.font = "VCR OSD Mono";
-		fnfFreeplay.visible = false;
-		var sillyStroke = new StrokeShader(0xFFFFFFFF, 2, 2);
-		fnfFreeplay.shader = sillyStroke;
-		add(fnfFreeplay);
-
-		var fnfHighscoreSpr:FlxSprite = new FlxSprite(890, 70);
-		fnfHighscoreSpr.frames = Paths.getSparrowAtlas('freeplay/highscore');
-		fnfHighscoreSpr.animation.addByPrefix("highscore", "highscore", 24, false);
-		fnfHighscoreSpr.visible = false;
-		fnfHighscoreSpr.setGraphicSize(0, Std.int(fnfHighscoreSpr.height * 1));
-		fnfHighscoreSpr.antialiasing = true;
-		fnfHighscoreSpr.updateHitbox();
-		add(fnfHighscoreSpr);
-
-		new FlxTimer().start(FlxG.random.float(12, 50), function(tmr)
-		{
-			fnfHighscoreSpr.animation.play("highscore");
-			tmr.time = FlxG.random.float(20, 60);
-		}, 0);
-
-		fp = new FreeplayScore(460, 60, 100);
-		fp.visible = false;
-		add(fp);
-
-		txtCompletion = new FlxText(1200, 77, 0, "0", 32);
-		txtCompletion.font = "VCR OSD Mono";
-		txtCompletion.visible = false;
-		add(txtCompletion);
-
-		dj.onIntroDone.add(function()
-		{
-			FlxTween.tween(grpDifficulties, {x: 90}, 0.6, {ease: FlxEase.quartOut});
-
-			add(new DifficultySelector(20, grpDifficulties.y - 10, false, controls));
-			add(new DifficultySelector(325, grpDifficulties.y - 10, true, controls));
-
-			var letterSort:LetterSort = new LetterSort(300, 100);
-			add(letterSort);
-
-			letterSort.changeSelectionCallback = (str) ->
-			{
-				switch (str)
-				{
-					case "fav":
-						generateSongList({filterType: FAVORITE}, true);
-					case "ALL":
-						generateSongList(null, true);
-					default:
-						generateSongList({filterType: STARTSWITH, filterData: str}, true);
-				}
-			};
-
-			new FlxTimer().start(1 / 24, function(handShit)
-			{
-				fnfHighscoreSpr.visible = true;
-				fnfFreeplay.visible = true;
-				fp.visible = true;
-				fp.updateScore(0);
-
-				txtCompletion.visible = true;
-				intendedCompletion = 0;
-
-				new FlxTimer().start(1.5 / 24, function(bold)
-				{
-					sillyStroke.width = 0;
-					sillyStroke.height = 0;
-				});
-			});
-
-			pinkBack.color = 0xFFffd863;
-			// fnfFreeplay.visible = true;
-			bgDad.visible = true;
-			orangeBackShit.visible = true;
-			alsoOrangeLOL.visible = true;
-			grpTxtScrolls.visible = true;
-		});
-
-		generateSongList();
-
-		// FlxG.sound.playMusic(Paths.music('title'), 0);
-		// FlxG.sound.music.fadeIn(2, 0, 0.8);
-		// selector = new FlxText();
-
-		// selector.size = 40;
-		// selector.text = ">";
-		// add(selector);
-
-		var swag:Alphabet = new Alphabet(1, 0, "swag");
-
-		// JUST DOIN THIS SHIT FOR TESTING!!!
-		/* 
-			var md:String = Markdown.markdownToHtml(Assets.getText('CHANGELOG.md'));
-
-			var texFel:TextField = new TextField();
-			texFel.width = FlxG.width;
-			texFel.height = FlxG.height;
-			// texFel.
-			texFel.htmlText = md;
-
-			FlxG.stage.addChild(texFel);
-
-			trace(md);
-		 */
-
-		var funnyCam = new FlxCamera(0, 0, FlxG.width, FlxG.height);
-		funnyCam.bgColor = FlxColor.TRANSPARENT;
-		FlxG.cameras.add(funnyCam);
-
-		typing = new FlxInputText(100, 100);
-		add(typing);
-
-		typing.callback = function(txt, action)
-		{
-			// generateSongList(new EReg(txt.trim(), "ig"));
-			trace(action);
-		};
-
-		forEach(function(bs)
-		{
-			bs.cameras = [funnyCam];
-		});
-
-		super.create();
-	}
-
-	public function generateSongList(?filterStuff:SongFilter, ?force:Bool = false)
-	{
-		curSelected = 0;
-
-		grpCapsules.clear();
-
-		// var regexp:EReg = regexp;
-		var tempSongs:Array<SongMetadata> = songs;
-
-		if (filterStuff != null)
-		{
-			switch (filterStuff.filterType)
-			{
-				case STARTSWITH:
-					tempSongs = tempSongs.filter(str ->
-					{
-						return str.songName.toLowerCase().startsWith(filterStuff.filterData);
-					});
-				case ALL:
-				// no filter!
-				case FAVORITE:
-					tempSongs = tempSongs.filter(str ->
-					{
-						return str.isFav;
-					});
-				default:
-					// return all on default
-			}
-		}
-
-		// if (regexp != null)
-		// 	tempSongs = songs.filter(item -> regexp.match(item.songName));
-
-		// tempSongs.sort(function(a, b):Int
-		// {
-		// 	var tempA = a.songName.toUpperCase();
-		// 	var tempB = b.songName.toUpperCase();
-
-		// 	if (tempA < tempB)
-		// 		return -1;
-		// 	else if (tempA > tempB)
-		// 		return 1;
-		// 	else
-		// 		return 0;
-		// });
-
-		for (i in 0...tempSongs.length)
-		{
-			var funnyMenu:SongMenuItem = new SongMenuItem(FlxG.width, (i * 150) + 160, tempSongs[i].songName);
-			funnyMenu.targetPos.x = funnyMenu.x;
-			funnyMenu.ID = i;
-			funnyMenu.alpha = 0.5;
-			funnyMenu.songText.visible = false;
-			funnyMenu.favIcon.visible = tempSongs[i].isFav;
-
-			// fp.updateScore(0);
-
-			new FlxTimer().start((1 / 24) * i, function(doShit)
-			{
-				funnyMenu.doJumpIn = true;
-			});
-
-			new FlxTimer().start((0.09 * i) + 0.85, function(lerpTmr)
-			{
-				funnyMenu.doLerp = true;
-			});
-
-			if (!force)
-			{
-				new FlxTimer().start(((0.20 * i) / (1 + i)) + 0.75, function(swagShi)
-				{
-					funnyMenu.songText.visible = true;
-					funnyMenu.alpha = 1;
-				});
-			}
-			else
-			{
-				funnyMenu.songText.visible = true;
-				funnyMenu.alpha = 1;
-			}
-
-			grpCapsules.add(funnyMenu);
-
-			var songText:Alphabet = new Alphabet(0, (70 * i) + 30, tempSongs[i].songName, true, false);
-			songText.x += 100;
-			songText.isMenuItem = true;
-			songText.targetY = i;
-
-			// grpSongs.add(songText);
-
-			var icon:HealthIcon = new HealthIcon(tempSongs[i].songCharacter);
-			// icon.sprTracker = songText;
-
-			// using a FlxGroup is too much fuss!
-			iconArray.push(icon);
-			// add(icon);
-
-			// songText.x += 40;
-			// DONT PUT X IN THE FIRST PARAMETER OF new ALPHABET() !!
-			// songText.screenCenter(X);
-		}
-
-		changeSelection();
-		changeDiff();
-	}
-
-	public function addSong(songName:String, weekNum:Int, songCharacter:String)
-	{
-		songs.push(new SongMetadata(songName, weekNum, songCharacter));
-	}
-
-	public function addWeek(songs:Array<String>, weekNum:Int, ?songCharacters:Array<String>)
-	{
-		if (songCharacters == null)
-			songCharacters = ['bf'];
-
-		var num:Int = 0;
-		for (song in songs)
-		{
-			addSong(song, weekNum, songCharacters[num]);
-
-			if (songCharacters.length != 1)
-				num++;
-		}
-	}
-
-	var touchY:Float = 0;
-	var touchX:Float = 0;
-	var dxTouch:Float = 0;
-	var dyTouch:Float = 0;
-	var velTouch:Float = 0;
-
-	var veloctiyLoopShit:Float = 0;
-	var touchTimer:Float = 0;
-
-	var initTouchPos:FlxPoint = new FlxPoint();
-
-	var spamTimer:Float = 0;
-	var spamming:Bool = false;
-
-	override function update(elapsed:Float)
-	{
-		super.update(elapsed);
-
-		if (FlxG.keys.justPressed.F)
-		{
-			var realShit = curSelected;
-			songs[curSelected].isFav = !songs[curSelected].isFav;
-			if (songs[curSelected].isFav)
-			{
-				FlxTween.tween(grpCapsules.members[realShit], {angle: 360}, 0.4, {
-					ease: FlxEase.elasticOut,
-					onComplete: _ ->
-					{
-						grpCapsules.members[realShit].favIcon.visible = true;
-						grpCapsules.members[realShit].favIcon.animation.play("fav");
-					}
-				});
-			}
-			else
-			{
-				grpCapsules.members[realShit].favIcon.animation.play('fav', false, true);
-				new FlxTimer().start((1 / 24) * 14, _ ->
-				{
-					grpCapsules.members[realShit].favIcon.visible = false;
-				});
-				new FlxTimer().start((1 / 24) * 24, _ ->
-				{
-					FlxTween.tween(grpCapsules.members[realShit], {angle: 0}, 0.4, {ease: FlxEase.elasticOut});
-				});
-			}
-		}
-
-		if (FlxG.keys.justPressed.T)
-			typing.hasFocus = true;
-
-		if (FlxG.sound.music != null)
-		{
-			if (FlxG.sound.music.volume < 0.7)
-			{
-				FlxG.sound.music.volume += 0.5 * elapsed;
-			}
-		}
-
-		lerpScore = CoolUtil.coolLerp(lerpScore, intendedScore, 0.2);
-		lerpCompletion = CoolUtil.coolLerp(lerpCompletion, intendedCompletion, 0.9);
-
-		fp.updateScore(Std.int(lerpScore));
-
-		txtCompletion.text = Math.floor(lerpCompletion * 100) + "%";
-		trace(Highscore.getCompletion(songs[curSelected].songName, curDifficulty));
-
-		// trace(intendedScore);
-		// trace(lerpScore);
-		// Highscore.getAllScores();
-
-		var upP = controls.UI_UP_P;
-		var downP = controls.UI_DOWN_P;
-		var accepted = controls.ACCEPT;
-
-		if (FlxG.onMobile)
-		{
-			for (touch in FlxG.touches.list)
-			{
-				if (touch.justPressed)
-				{
-					initTouchPos.set(touch.screenX, touch.screenY);
-				}
-				if (touch.pressed)
-				{
-					var dx = initTouchPos.x - touch.screenX;
-					var dy = initTouchPos.y - touch.screenY;
-
-					var angle = Math.atan2(dy, dx);
-					var length = Math.sqrt(dx * dx + dy * dy);
-
-					FlxG.watch.addQuick("LENGTH", length);
-					FlxG.watch.addQuick("ANGLE", Math.round(FlxAngle.asDegrees(angle)));
-					trace("ANGLE", Math.round(FlxAngle.asDegrees(angle)));
-				}
-
-				/* switch (inputID)
-					{
-						case FlxObject.UP:
-							return
-						case FlxObject.DOWN:
-					}
-				 */
-			}
-
-			if (FlxG.touches.getFirst() != null)
-			{
-				if (touchTimer >= 1.5)
-					accepted = true;
-
-				touchTimer += elapsed;
-				var touch:FlxTouch = FlxG.touches.getFirst();
-
-				velTouch = Math.abs((touch.screenY - dyTouch)) / 50;
-
-				dyTouch = touch.screenY - touchY;
-				dxTouch = touch.screenX - touchX;
-
-				if (touch.justPressed)
-				{
-					touchY = touch.screenY;
-					dyTouch = 0;
-					velTouch = 0;
-
-					touchX = touch.screenX;
-					dxTouch = 0;
-				}
-
-				if (Math.abs(dxTouch) >= 100)
-				{
-					touchX = touch.screenX;
-					if (dxTouch != 0)
-						dxTouch < 0 ? changeDiff(1) : changeDiff(-1);
-				}
-
-				if (Math.abs(dyTouch) >= 100)
-				{
-					touchY = touch.screenY;
-
-					if (dyTouch != 0)
-						dyTouch < 0 ? changeSelection(1) : changeSelection(-1);
-					// changeSelection(1);
-				}
-			}
-			else
-			{
-				touchTimer = 0;
-			}
-		}
-
-		#if mobile
-		for (touch in FlxG.touches.list)
-		{
-			if (touch.justPressed)
-			{
-				// accepted = true;
-			}
-		}
-		#end
-
-		if (controls.UI_UP || controls.UI_DOWN)
-		{
-			spamTimer += elapsed;
-
-			if (spamming)
-			{
-				if (spamTimer >= 0.07)
-				{
-					spamTimer = 0;
-
-					if (controls.UI_UP)
-						changeSelection(-1);
-					else
-						changeSelection(1);
-				}
-			}
-			else if (spamTimer >= 0.9)
-				spamming = true;
-		}
-		else
-		{
-			spamming = false;
-			spamTimer = 0;
-		}
-
-		if (upP)
-		{
-			dj.resetAFKTimer();
-			changeSelection(-1);
-		}
-		if (downP)
-		{
-			dj.resetAFKTimer();
-			changeSelection(1);
-		}
-
-		if (FlxG.mouse.wheel != 0)
-		{
-			dj.resetAFKTimer();
-			changeSelection(-Math.round(FlxG.mouse.wheel / 4));
-		}
-
-		if (controls.UI_LEFT_P)
-		{
-			dj.resetAFKTimer();
-			changeDiff(-1);
-		}
-		if (controls.UI_RIGHT_P)
-		{
-			dj.resetAFKTimer();
-			changeDiff(1);
-		}
-
-		if (controls.BACK && !typing.hasFocus)
-		{
-			FlxG.sound.play(Paths.sound('cancelMenu'));
-
-			FlxTransitionableState.skipNextTransIn = true;
-			FlxTransitionableState.skipNextTransOut = true;
-			FlxG.switchState(new MainMenuState());
-		}
-
-		if (accepted)
-		{
-			// if (Assets.exists())
-
-			var poop:String = songs[curSelected].songName.toLowerCase();
-
-			// does not work properly, always just accidentally sets it to normal anyways!
-			/* if (!Assets.exists(Paths.json(songs[curSelected].songName + '/' + poop)))
-				{
-					// defaults to normal if HARD / EASY doesn't exist
-					// does not account if NORMAL doesn't exist!
-					FlxG.log.warn("CURRENT DIFFICULTY IS NOT CHARTED, DEFAULTING TO NORMAL!");
-					poop = Highscore.formatSong(songs[curSelected].songName.toLowerCase(), 1);
-					curDifficulty = 1;
-			}*/
-
-			PlayState.currentSong = SongLoad.loadFromJson(poop, songs[curSelected].songName.toLowerCase());
-			PlayState.currentSong_NEW = SongDataParser.fetchSong(songs[curSelected].songName.toLowerCase());
-			PlayState.isStoryMode = false;
-			PlayState.storyDifficulty = curDifficulty;
-			PlayState.storyDifficulty_NEW = switch (curDifficulty)
-			{
-				case 0:
-					'easy';
-				case 1:
-					'normal';
-				case 2:
-					'hard';
-				default: 'normal';
-			};
-			// SongLoad.curDiff = Highscore.formatSong()
-
-			SongLoad.curDiff = PlayState.storyDifficulty_NEW;
-
-			PlayState.storyWeek = songs[curSelected].week;
-			trace(' CUR WEEK ' + PlayState.storyWeek);
-
-			// Visual and audio effects.
-			FlxG.sound.play(Paths.sound('confirmMenu'));
-			dj.confirm();
-
-			new FlxTimer().start(1, function(tmr:FlxTimer)
-			{
-				LoadingState.loadAndSwitchState(new PlayState(), true);
-			});
-		}
-	}
-
-	override function switchTo(nextState:FlxState):Bool
-	{
-		clearDaCache(songs[curSelected].songName);
-		return super.switchTo(nextState);
-	}
-
-	function changeDiff(change:Int = 0)
-	{
-		touchTimer = 0;
-
-		curDifficulty += change;
-
-		if (curDifficulty < 0)
-			curDifficulty = 2;
-		if (curDifficulty > 2)
-			curDifficulty = 0;
-
-		// intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
-		intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
-		intendedCompletion = Highscore.getCompletion(songs[curSelected].songName, curDifficulty);
-
-		PlayState.storyDifficulty = curDifficulty;
-		PlayState.storyDifficulty_NEW = switch (curDifficulty)
-		{
-			case 0:
-				'easy';
-			case 1:
-				'normal';
-			case 2:
-				'hard';
-			default:
-				'normal';
-		};
-
-		grpDifficulties.group.forEach(function(spr)
-		{
-			spr.visible = false;
-		});
-
-		var curShit:FlxSprite = grpDifficulties.group.members[curDifficulty];
-
-		curShit.visible = true;
-		curShit.offset.y += 5;
-		curShit.alpha = 0.5;
-		new FlxTimer().start(1 / 24, function(swag)
-		{
-			curShit.alpha = 1;
-			curShit.updateHitbox();
-		});
-	}
-
-	// Clears the cache of songs, frees up memory, they' ll have to be loaded in later tho function clearDaCache(actualSongTho:String)
-	function clearDaCache(actualSongTho:String)
-	{
-		for (song in songs)
-		{
-			if (song.songName != actualSongTho)
-			{
-				trace('trying to remove: ' + song.songName);
-				// openfl.Assets.cache.clear(Paths.inst(song.songName));
-			}
-		}
-	}
-
-	function changeSelection(change:Int = 0)
-	{
-		// fp.updateScore(12345);
-
-		NGio.logEvent('Fresh');
-
-		// NGio.logEvent('Fresh');
-		FlxG.sound.play(Paths.sound('scrollMenu'), 0.4);
-
-		curSelected += change;
-
-		if (curSelected < 0)
-			curSelected = grpCapsules.members.length - 1;
-		if (curSelected >= grpCapsules.members.length)
-			curSelected = 0;
-
-		// selector.y = (70 * curSelected) + 30;
-
-		// intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
-		intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
-		intendedCompletion = Highscore.getCompletion(songs[curSelected].songName, curDifficulty);
-		// lerpScore = 0;
-
-		#if PRELOAD_ALL
-		// FlxG.sound.playMusic(Paths.inst(songs[curSelected].songName), 0);
-		#end
-
-		var bullShit:Int = 0;
-
-		for (i in 0...iconArray.length)
-		{
-			iconArray[i].alpha = 0.6;
-		}
-
-		iconArray[curSelected].alpha = 1;
-
-		for (index => capsule in grpCapsules.members)
-		{
-			capsule.selected = false;
-
-			capsule.targetPos.y = ((index - curSelected) * 150) + 160;
-			capsule.targetPos.x = 270 + (60 * (Math.sin(index - curSelected)));
-			// capsule.targetPos.x = 320 + (40 * (index - curSelected));
-
-			if (index < curSelected)
-				capsule.targetPos.y -= 100; // another 100 for good measure
-		}
-
-		if (grpCapsules.members.length > 0)
-			grpCapsules.members[curSelected].selected = true;
-	}
+    var moreWays2:BGScrollingText = new BGScrollingText(0, 400, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width);
+    moreWays2.funnyColor = 0xFFfff383;
+    moreWays2.speed = 4.4;
+    grpTxtScrolls.add(moreWays2);
+
+    var funnyScroll3:BGScrollingText = new BGScrollingText(0, orangeBackShit.y, "BOYFRIEND", FlxG.width / 2);
+    funnyScroll3.funnyColor = 0xFFff9963;
+    funnyScroll3.speed = -0.8;
+    grpTxtScrolls.add(funnyScroll3);
+
+    dj = new DJBoyfriend(0, -100);
+    add(dj);
+
+    var bgDad:FlxSprite = new FlxSprite(pinkBack.width * 0.75, 0).loadGraphic(Paths.image('freeplay/freeplayBGdad'));
+    bgDad.setGraphicSize(0, FlxG.height);
+    bgDad.updateHitbox();
+    bgDad.shader = new AngleMask();
+    bgDad.visible = false;
+
+    var blackOverlayBullshitLOLXD:FlxSprite = new FlxSprite(FlxG.width).makeGraphic(Std.int(bgDad.width), Std.int(bgDad.height), FlxColor.BLACK);
+    add(blackOverlayBullshitLOLXD); // used to mask the text lol!
+
+    add(bgDad);
+    FlxTween.tween(blackOverlayBullshitLOLXD, {x: pinkBack.width * 0.75}, 1, {ease: FlxEase.quintOut});
+
+    blackOverlayBullshitLOLXD.shader = bgDad.shader;
+
+    grpSongs = new FlxTypedGroup<Alphabet>();
+    add(grpSongs);
+
+    grpCapsules = new FlxTypedGroup<SongMenuItem>();
+    add(grpCapsules);
+
+    grpDifficulties = new FlxSpriteGroup(-300, 80);
+    add(grpDifficulties);
+
+    grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayEasy')));
+    grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayNorm')));
+    grpDifficulties.add(new FlxSprite().loadGraphic(Paths.image('freeplay/freeplayHard')));
+
+    grpDifficulties.group.forEach(function(spr)
+    {
+      spr.visible = false;
+    });
+
+    grpDifficulties.group.members[curDifficulty].visible = true;
+
+    var overhangStuff:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, 64, FlxColor.BLACK);
+    overhangStuff.y -= overhangStuff.height;
+    add(overhangStuff);
+    FlxTween.tween(overhangStuff, {y: 0}, 0.3, {ease: FlxEase.quartOut});
+
+    var fnfFreeplay:FlxText = new FlxText(0, 12, 0, "FREEPLAY", 48);
+    fnfFreeplay.font = "VCR OSD Mono";
+    fnfFreeplay.visible = false;
+    var sillyStroke = new StrokeShader(0xFFFFFFFF, 2, 2);
+    fnfFreeplay.shader = sillyStroke;
+    add(fnfFreeplay);
+
+    var fnfHighscoreSpr:FlxSprite = new FlxSprite(890, 70);
+    fnfHighscoreSpr.frames = Paths.getSparrowAtlas('freeplay/highscore');
+    fnfHighscoreSpr.animation.addByPrefix("highscore", "highscore", 24, false);
+    fnfHighscoreSpr.visible = false;
+    fnfHighscoreSpr.setGraphicSize(0, Std.int(fnfHighscoreSpr.height * 1));
+    fnfHighscoreSpr.antialiasing = true;
+    fnfHighscoreSpr.updateHitbox();
+    add(fnfHighscoreSpr);
+
+    new FlxTimer().start(FlxG.random.float(12, 50), function(tmr)
+    {
+      fnfHighscoreSpr.animation.play("highscore");
+      tmr.time = FlxG.random.float(20, 60);
+    }, 0);
+
+    fp = new FreeplayScore(460, 60, 100);
+    fp.visible = false;
+    add(fp);
+
+    txtCompletion = new FlxText(1200, 77, 0, "0", 32);
+    txtCompletion.font = "VCR OSD Mono";
+    txtCompletion.visible = false;
+    add(txtCompletion);
+
+    dj.onIntroDone.add(function()
+    {
+      FlxTween.tween(grpDifficulties, {x: 90}, 0.6, {ease: FlxEase.quartOut});
+
+      add(new DifficultySelector(20, grpDifficulties.y - 10, false, controls));
+      add(new DifficultySelector(325, grpDifficulties.y - 10, true, controls));
+
+      var letterSort:LetterSort = new LetterSort(300, 100);
+      add(letterSort);
+
+      letterSort.changeSelectionCallback = (str) ->
+      {
+        switch (str)
+        {
+          case "fav":
+            generateSongList({filterType: FAVORITE}, true);
+          case "ALL":
+            generateSongList(null, true);
+          default:
+            generateSongList({filterType: STARTSWITH, filterData: str}, true);
+        }
+      };
+
+      new FlxTimer().start(1 / 24, function(handShit)
+      {
+        fnfHighscoreSpr.visible = true;
+        fnfFreeplay.visible = true;
+        fp.visible = true;
+        fp.updateScore(0);
+
+        txtCompletion.visible = true;
+        intendedCompletion = 0;
+
+        new FlxTimer().start(1.5 / 24, function(bold)
+        {
+          sillyStroke.width = 0;
+          sillyStroke.height = 0;
+        });
+      });
+
+      pinkBack.color = 0xFFffd863;
+      // fnfFreeplay.visible = true;
+      bgDad.visible = true;
+      orangeBackShit.visible = true;
+      alsoOrangeLOL.visible = true;
+      grpTxtScrolls.visible = true;
+    });
+
+    generateSongList();
+
+    // FlxG.sound.playMusic(Paths.music('title'), 0);
+    // FlxG.sound.music.fadeIn(2, 0, 0.8);
+    // selector = new FlxText();
+
+    // selector.size = 40;
+    // selector.text = ">";
+    // add(selector);
+
+    var swag:Alphabet = new Alphabet(1, 0, "swag");
+
+    // JUST DOIN THIS SHIT FOR TESTING!!!
+    /* 
+      var md:String = Markdown.markdownToHtml(Assets.getText('CHANGELOG.md'));
+
+      var texFel:TextField = new TextField();
+      texFel.width = FlxG.width;
+      texFel.height = FlxG.height;
+      // texFel.
+      texFel.htmlText = md;
+
+      FlxG.stage.addChild(texFel);
+
+      trace(md);
+     */
+
+    var funnyCam = new FlxCamera(0, 0, FlxG.width, FlxG.height);
+    funnyCam.bgColor = FlxColor.TRANSPARENT;
+    FlxG.cameras.add(funnyCam);
+
+    typing = new FlxInputText(100, 100);
+    add(typing);
+
+    typing.callback = function(txt, action)
+    {
+      // generateSongList(new EReg(txt.trim(), "ig"));
+      trace(action);
+    };
+
+    forEach(function(bs)
+    {
+      bs.cameras = [funnyCam];
+    });
+
+    super.create();
+  }
+
+  public function generateSongList(?filterStuff:SongFilter, ?force:Bool = false)
+  {
+    curSelected = 0;
+
+    grpCapsules.clear();
+
+    // var regexp:EReg = regexp;
+    var tempSongs:Array<SongMetadata> = songs;
+
+    if (filterStuff != null)
+    {
+      switch (filterStuff.filterType)
+      {
+        case STARTSWITH:
+          tempSongs = tempSongs.filter(str ->
+          {
+            return str.songName.toLowerCase().startsWith(filterStuff.filterData);
+          });
+        case ALL:
+        // no filter!
+        case FAVORITE:
+          tempSongs = tempSongs.filter(str ->
+          {
+            return str.isFav;
+          });
+        default:
+          // return all on default
+      }
+    }
+
+    // if (regexp != null)
+    // 	tempSongs = songs.filter(item -> regexp.match(item.songName));
+
+    // tempSongs.sort(function(a, b):Int
+    // {
+    // 	var tempA = a.songName.toUpperCase();
+    // 	var tempB = b.songName.toUpperCase();
+
+    // 	if (tempA < tempB)
+    // 		return -1;
+    // 	else if (tempA > tempB)
+    // 		return 1;
+    // 	else
+    // 		return 0;
+    // });
+
+    for (i in 0...tempSongs.length)
+    {
+      var funnyMenu:SongMenuItem = new SongMenuItem(FlxG.width, (i * 150) + 160, tempSongs[i].songName);
+      funnyMenu.targetPos.x = funnyMenu.x;
+      funnyMenu.ID = i;
+      funnyMenu.alpha = 0.5;
+      funnyMenu.songText.visible = false;
+      funnyMenu.favIcon.visible = tempSongs[i].isFav;
+
+      // fp.updateScore(0);
+
+      new FlxTimer().start((1 / 24) * i, function(doShit)
+      {
+        funnyMenu.doJumpIn = true;
+      });
+
+      new FlxTimer().start((0.09 * i) + 0.85, function(lerpTmr)
+      {
+        funnyMenu.doLerp = true;
+      });
+
+      if (!force)
+      {
+        new FlxTimer().start(((0.20 * i) / (1 + i)) + 0.75, function(swagShi)
+        {
+          funnyMenu.songText.visible = true;
+          funnyMenu.alpha = 1;
+        });
+      }
+      else
+      {
+        funnyMenu.songText.visible = true;
+        funnyMenu.alpha = 1;
+      }
+
+      grpCapsules.add(funnyMenu);
+
+      var songText:Alphabet = new Alphabet(0, (70 * i) + 30, tempSongs[i].songName, true, false);
+      songText.x += 100;
+      songText.isMenuItem = true;
+      songText.targetY = i;
+
+      // grpSongs.add(songText);
+
+      var icon:HealthIcon = new HealthIcon(tempSongs[i].songCharacter);
+      // icon.sprTracker = songText;
+
+      // using a FlxGroup is too much fuss!
+      iconArray.push(icon);
+      // add(icon);
+
+      // songText.x += 40;
+      // DONT PUT X IN THE FIRST PARAMETER OF new ALPHABET() !!
+      // songText.screenCenter(X);
+    }
+
+    changeSelection();
+    changeDiff();
+  }
+
+  public function addSong(songName:String, weekNum:Int, songCharacter:String)
+  {
+    songs.push(new SongMetadata(songName, weekNum, songCharacter));
+  }
+
+  public function addWeek(songs:Array<String>, weekNum:Int, ?songCharacters:Array<String>)
+  {
+    if (songCharacters == null)
+      songCharacters = ['bf'];
+
+    var num:Int = 0;
+    for (song in songs)
+    {
+      addSong(song, weekNum, songCharacters[num]);
+
+      if (songCharacters.length != 1)
+        num++;
+    }
+  }
+
+  var touchY:Float = 0;
+  var touchX:Float = 0;
+  var dxTouch:Float = 0;
+  var dyTouch:Float = 0;
+  var velTouch:Float = 0;
+
+  var veloctiyLoopShit:Float = 0;
+  var touchTimer:Float = 0;
+
+  var initTouchPos:FlxPoint = new FlxPoint();
+
+  var spamTimer:Float = 0;
+  var spamming:Bool = false;
+
+  override function update(elapsed:Float)
+  {
+    super.update(elapsed);
+
+    if (FlxG.keys.justPressed.F)
+    {
+      var realShit = curSelected;
+      songs[curSelected].isFav = !songs[curSelected].isFav;
+      if (songs[curSelected].isFav)
+      {
+        FlxTween.tween(grpCapsules.members[realShit], {angle: 360}, 0.4, {
+          ease: FlxEase.elasticOut,
+          onComplete: _ ->
+          {
+            grpCapsules.members[realShit].favIcon.visible = true;
+            grpCapsules.members[realShit].favIcon.animation.play("fav");
+          }
+        });
+      }
+      else
+      {
+        grpCapsules.members[realShit].favIcon.animation.play('fav', false, true);
+        new FlxTimer().start((1 / 24) * 14, _ ->
+        {
+          grpCapsules.members[realShit].favIcon.visible = false;
+        });
+        new FlxTimer().start((1 / 24) * 24, _ ->
+        {
+          FlxTween.tween(grpCapsules.members[realShit], {angle: 0}, 0.4, {ease: FlxEase.elasticOut});
+        });
+      }
+    }
+
+    if (FlxG.keys.justPressed.T)
+      typing.hasFocus = true;
+
+    if (FlxG.sound.music != null)
+    {
+      if (FlxG.sound.music.volume < 0.7)
+      {
+        FlxG.sound.music.volume += 0.5 * elapsed;
+      }
+    }
+
+    lerpScore = CoolUtil.coolLerp(lerpScore, intendedScore, 0.2);
+    lerpCompletion = CoolUtil.coolLerp(lerpCompletion, intendedCompletion, 0.9);
+
+    fp.updateScore(Std.int(lerpScore));
+
+    txtCompletion.text = Math.floor(lerpCompletion * 100) + "%";
+    // trace(Highscore.getCompletion(songs[curSelected].songName, curDifficulty));
+
+    // trace(intendedScore);
+    // trace(lerpScore);
+    // Highscore.getAllScores();
+
+    var upP = controls.UI_UP_P;
+    var downP = controls.UI_DOWN_P;
+    var accepted = controls.ACCEPT;
+
+    if (FlxG.onMobile)
+    {
+      for (touch in FlxG.touches.list)
+      {
+        if (touch.justPressed)
+        {
+          initTouchPos.set(touch.screenX, touch.screenY);
+        }
+        if (touch.pressed)
+        {
+          var dx = initTouchPos.x - touch.screenX;
+          var dy = initTouchPos.y - touch.screenY;
+
+          var angle = Math.atan2(dy, dx);
+          var length = Math.sqrt(dx * dx + dy * dy);
+
+          FlxG.watch.addQuick("LENGTH", length);
+          FlxG.watch.addQuick("ANGLE", Math.round(FlxAngle.asDegrees(angle)));
+          trace("ANGLE", Math.round(FlxAngle.asDegrees(angle)));
+        }
+
+        /* switch (inputID)
+          {
+            case FlxObject.UP:
+              return
+            case FlxObject.DOWN:
+          }
+         */
+      }
+
+      if (FlxG.touches.getFirst() != null)
+      {
+        if (touchTimer >= 1.5)
+          accepted = true;
+
+        touchTimer += elapsed;
+        var touch:FlxTouch = FlxG.touches.getFirst();
+
+        velTouch = Math.abs((touch.screenY - dyTouch)) / 50;
+
+        dyTouch = touch.screenY - touchY;
+        dxTouch = touch.screenX - touchX;
+
+        if (touch.justPressed)
+        {
+          touchY = touch.screenY;
+          dyTouch = 0;
+          velTouch = 0;
+
+          touchX = touch.screenX;
+          dxTouch = 0;
+        }
+
+        if (Math.abs(dxTouch) >= 100)
+        {
+          touchX = touch.screenX;
+          if (dxTouch != 0)
+            dxTouch < 0 ? changeDiff(1) : changeDiff(-1);
+        }
+
+        if (Math.abs(dyTouch) >= 100)
+        {
+          touchY = touch.screenY;
+
+          if (dyTouch != 0)
+            dyTouch < 0 ? changeSelection(1) : changeSelection(-1);
+          // changeSelection(1);
+        }
+      }
+      else
+      {
+        touchTimer = 0;
+      }
+    }
+
+    #if mobile
+    for (touch in FlxG.touches.list)
+    {
+      if (touch.justPressed)
+      {
+        // accepted = true;
+      }
+    }
+    #end
+
+    if (controls.UI_UP || controls.UI_DOWN)
+    {
+      spamTimer += elapsed;
+
+      if (spamming)
+      {
+        if (spamTimer >= 0.07)
+        {
+          spamTimer = 0;
+
+          if (controls.UI_UP)
+            changeSelection(-1);
+          else
+            changeSelection(1);
+        }
+      }
+      else if (spamTimer >= 0.9)
+        spamming = true;
+    }
+    else
+    {
+      spamming = false;
+      spamTimer = 0;
+    }
+
+    if (upP)
+    {
+      dj.resetAFKTimer();
+      changeSelection(-1);
+    }
+    if (downP)
+    {
+      dj.resetAFKTimer();
+      changeSelection(1);
+    }
+
+    if (FlxG.mouse.wheel != 0)
+    {
+      dj.resetAFKTimer();
+      changeSelection(-Math.round(FlxG.mouse.wheel / 4));
+    }
+
+    if (controls.UI_LEFT_P)
+    {
+      dj.resetAFKTimer();
+      changeDiff(-1);
+    }
+    if (controls.UI_RIGHT_P)
+    {
+      dj.resetAFKTimer();
+      changeDiff(1);
+    }
+
+    if (controls.BACK && !typing.hasFocus)
+    {
+      FlxG.sound.play(Paths.sound('cancelMenu'));
+
+      FlxTransitionableState.skipNextTransIn = true;
+      FlxTransitionableState.skipNextTransOut = true;
+      FlxG.switchState(new MainMenuState());
+    }
+
+    if (accepted)
+    {
+      // if (Assets.exists())
+
+      var poop:String = songs[curSelected].songName.toLowerCase();
+
+      // does not work properly, always just accidentally sets it to normal anyways!
+      /* if (!Assets.exists(Paths.json(songs[curSelected].songName + '/' + poop)))
+        {
+          // defaults to normal if HARD / EASY doesn't exist
+          // does not account if NORMAL doesn't exist!
+          FlxG.log.warn("CURRENT DIFFICULTY IS NOT CHARTED, DEFAULTING TO NORMAL!");
+          poop = Highscore.formatSong(songs[curSelected].songName.toLowerCase(), 1);
+          curDifficulty = 1;
+      }*/
+
+      // TODO: Deprecate and remove this entirely once all songs are converted to the new format
+      PlayState.currentSong = SongLoad.loadFromJson(poop, songs[curSelected].songName.toLowerCase());
+      PlayState.currentSong_NEW = SongDataParser.fetchSong(songs[curSelected].songName.toLowerCase());
+      PlayState.isStoryMode = false;
+      PlayState.storyDifficulty = curDifficulty;
+      PlayState.storyDifficulty_NEW = switch (curDifficulty)
+      {
+        case 0:
+          'easy';
+        case 1:
+          'normal';
+        case 2:
+          'hard';
+        default: 'normal';
+      };
+      // SongLoad.curDiff = Highscore.formatSong()
+
+      SongLoad.curDiff = PlayState.storyDifficulty_NEW;
+
+      PlayState.storyWeek = songs[curSelected].week;
+      // trace(' CUR WEEK ' + PlayState.storyWeek);
+
+      // Visual and audio effects.
+      FlxG.sound.play(Paths.sound('confirmMenu'));
+      dj.confirm();
+
+      new FlxTimer().start(1, function(tmr:FlxTimer)
+      {
+        LoadingState.loadAndSwitchState(new PlayState(), true);
+      });
+    }
+  }
+
+  override function switchTo(nextState:FlxState):Bool
+  {
+    clearDaCache(songs[curSelected].songName);
+    return super.switchTo(nextState);
+  }
+
+  function changeDiff(change:Int = 0)
+  {
+    touchTimer = 0;
+
+    curDifficulty += change;
+
+    if (curDifficulty < 0)
+      curDifficulty = 2;
+    if (curDifficulty > 2)
+      curDifficulty = 0;
+
+    // intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
+    intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
+    intendedCompletion = Highscore.getCompletion(songs[curSelected].songName, curDifficulty);
+
+    PlayState.storyDifficulty = curDifficulty;
+    PlayState.storyDifficulty_NEW = switch (curDifficulty)
+    {
+      case 0:
+        'easy';
+      case 1:
+        'normal';
+      case 2:
+        'hard';
+      default:
+        'normal';
+    };
+
+    grpDifficulties.group.forEach(function(spr)
+    {
+      spr.visible = false;
+    });
+
+    var curShit:FlxSprite = grpDifficulties.group.members[curDifficulty];
+
+    curShit.visible = true;
+    curShit.offset.y += 5;
+    curShit.alpha = 0.5;
+    new FlxTimer().start(1 / 24, function(swag)
+    {
+      curShit.alpha = 1;
+      curShit.updateHitbox();
+    });
+  }
+
+  // Clears the cache of songs, frees up memory, they' ll have to be loaded in later tho function clearDaCache(actualSongTho:String)
+  function clearDaCache(actualSongTho:String)
+  {
+    for (song in songs)
+    {
+      if (song.songName != actualSongTho)
+      {
+        trace('trying to remove: ' + song.songName);
+        // openfl.Assets.cache.clear(Paths.inst(song.songName));
+      }
+    }
+  }
+
+  function changeSelection(change:Int = 0)
+  {
+    // fp.updateScore(12345);
+
+    NGio.logEvent('Fresh');
+
+    // NGio.logEvent('Fresh');
+    FlxG.sound.play(Paths.sound('scrollMenu'), 0.4);
+
+    curSelected += change;
+
+    if (curSelected < 0)
+      curSelected = grpCapsules.members.length - 1;
+    if (curSelected >= grpCapsules.members.length)
+      curSelected = 0;
+
+    // selector.y = (70 * curSelected) + 30;
+
+    // intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
+    intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
+    intendedCompletion = Highscore.getCompletion(songs[curSelected].songName, curDifficulty);
+    // lerpScore = 0;
+
+    #if PRELOAD_ALL
+    // FlxG.sound.playMusic(Paths.inst(songs[curSelected].songName), 0);
+    #end
+
+    var bullShit:Int = 0;
+
+    for (i in 0...iconArray.length)
+    {
+      iconArray[i].alpha = 0.6;
+    }
+
+    iconArray[curSelected].alpha = 1;
+
+    for (index => capsule in grpCapsules.members)
+    {
+      capsule.selected = false;
+
+      capsule.targetPos.y = ((index - curSelected) * 150) + 160;
+      capsule.targetPos.x = 270 + (60 * (Math.sin(index - curSelected)));
+      // capsule.targetPos.x = 320 + (40 * (index - curSelected));
+
+      if (index < curSelected)
+        capsule.targetPos.y -= 100; // another 100 for good measure
+    }
+
+    if (grpCapsules.members.length > 0)
+      grpCapsules.members[curSelected].selected = true;
+  }
 }
 
 class DifficultySelector extends FlxSprite
 {
-	var controls:Controls;
-	var whiteShader:PureColor;
+  var controls:Controls;
+  var whiteShader:PureColor;
 
-	public function new(x:Float, y:Float, flipped:Bool, controls:Controls)
-	{
-		super(x, y);
+  public function new(x:Float, y:Float, flipped:Bool, controls:Controls)
+  {
+    super(x, y);
 
-		this.controls = controls;
+    this.controls = controls;
 
-		frames = Paths.getSparrowAtlas('freeplay/freeplaySelector');
-		animation.addByPrefix('shine', "arrow pointer loop", 24);
-		animation.play('shine');
+    frames = Paths.getSparrowAtlas('freeplay/freeplaySelector');
+    animation.addByPrefix('shine', "arrow pointer loop", 24);
+    animation.play('shine');
 
-		whiteShader = new PureColor(FlxColor.WHITE);
+    whiteShader = new PureColor(FlxColor.WHITE);
 
-		shader = whiteShader;
+    shader = whiteShader;
 
-		flipX = flipped;
-	}
+    flipX = flipped;
+  }
 
-	override function update(elapsed:Float)
-	{
-		if (flipX && controls.UI_RIGHT_P)
-			moveShitDown();
-		if (!flipX && controls.UI_LEFT_P)
-			moveShitDown();
+  override function update(elapsed:Float)
+  {
+    if (flipX && controls.UI_RIGHT_P)
+      moveShitDown();
+    if (!flipX && controls.UI_LEFT_P)
+      moveShitDown();
 
-		super.update(elapsed);
-	}
+    super.update(elapsed);
+  }
 
-	function moveShitDown()
-	{
-		offset.y -= 5;
+  function moveShitDown()
+  {
+    offset.y -= 5;
 
-		whiteShader.colorSet = true;
+    whiteShader.colorSet = true;
 
-		new FlxTimer().start(2 / 24, function(tmr)
-		{
-			whiteShader.colorSet = false;
-			updateHitbox();
-		});
-	}
+    new FlxTimer().start(2 / 24, function(tmr)
+    {
+      whiteShader.colorSet = false;
+      updateHitbox();
+    });
+  }
 }
 
 typedef SongFilter =
 {
-	var filterType:FilterType;
-	var ?filterData:Dynamic;
+  var filterType:FilterType;
+  var ?filterData:Dynamic;
 }
 
 enum abstract FilterType(String)
 {
-	var STARTSWITH;
-	var FAVORITE;
-	var ALL;
+  var STARTSWITH;
+  var FAVORITE;
+  var ALL;
 }
 
 class SongMetadata
 {
-	public var songName:String = "";
-	public var week:Int = 0;
-	public var songCharacter:String = "";
-	public var isFav:Bool = false;
+  public var songName:String = "";
+  public var week:Int = 0;
+  public var songCharacter:String = "";
+  public var isFav:Bool = false;
 
-	public function new(song:String, week:Int, songCharacter:String, ?isFav:Bool = false)
-	{
-		this.songName = song;
-		this.week = week;
-		this.songCharacter = songCharacter;
-		this.isFav = isFav;
-	}
+  public function new(song:String, week:Int, songCharacter:String, ?isFav:Bool = false)
+  {
+    this.songName = song;
+    this.week = week;
+    this.songCharacter = songCharacter;
+    this.isFav = isFav;
+  }
 }
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 7f192c170..85dc4e4cb 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -11,7 +11,7 @@ import flixel.util.FlxColor;
 import funkin.modding.module.ModuleHandler;
 import funkin.play.PlayState;
 import funkin.play.character.CharacterData.CharacterDataParser;
-import funkin.play.event.SongEvent.SongEventHandler;
+import funkin.play.event.SongEvent.SongEventParser;
 import funkin.play.song.SongData.SongDataParser;
 import funkin.play.stage.StageData;
 import funkin.ui.PreferencesMenu;
@@ -32,196 +32,199 @@ import Discord.DiscordClient;
  */
 class InitState extends FlxTransitionableState
 {
-	override public function create():Void
-	{
-		trace('This is a debug build, loading InitState...');
-		#if android
-		FlxG.android.preventDefaultKeys = [flixel.input.android.FlxAndroidKey.BACK];
-		#end
-		#if newgrounds
-		NGio.init();
-		#end
-		#if discord_rpc
-		DiscordClient.initialize();
+  override public function create():Void
+  {
+    trace('This is a debug build, loading InitState...');
+    #if android
+    FlxG.android.preventDefaultKeys = [flixel.input.android.FlxAndroidKey.BACK];
+    #end
+    #if newgrounds
+    NGio.init();
+    #end
+    #if discord_rpc
+    DiscordClient.initialize();
 
-		Application.current.onExit.add(function(exitCode)
-		{
-			DiscordClient.shutdown();
-		});
-		#end
+    Application.current.onExit.add(function(exitCode)
+    {
+      DiscordClient.shutdown();
+    });
+    #end
 
-		// ==== flixel shit ==== //
+    // ==== flixel shit ==== //
 
-		// This big obnoxious white button is for MOBILE, so that you can press it
-		// easily with your finger when debug bullshit pops up during testing lol!
-		FlxG.debugger.addButton(LEFT, new BitmapData(200, 200), function()
-		{
-			FlxG.debugger.visible = false;
-		});
+    // This big obnoxious white button is for MOBILE, so that you can press it
+    // easily with your finger when debug bullshit pops up during testing lol!
+    FlxG.debugger.addButton(LEFT, new BitmapData(200, 200), function()
+    {
+      FlxG.debugger.visible = false;
+    });
 
-		FlxG.sound.muteKeys = [ZERO];
-		FlxG.game.focusLostFramerate = 60;
+    FlxG.sound.muteKeys = [ZERO];
+    FlxG.game.focusLostFramerate = 60;
 
-		// FlxG.stage.window.borderless = true;
-		// FlxG.stage.window.mouseLock = true;
+    // FlxG.stage.window.borderless = true;
+    // FlxG.stage.window.mouseLock = true;
 
-		var diamond:FlxGraphic = FlxGraphic.fromClass(GraphicTransTileDiamond);
-		diamond.persist = true;
-		diamond.destroyOnNoUse = false;
+    var diamond:FlxGraphic = FlxGraphic.fromClass(GraphicTransTileDiamond);
+    diamond.persist = true;
+    diamond.destroyOnNoUse = false;
 
-		FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(0, -1), {asset: diamond, width: 32, height: 32},
-			new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
-		FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(0, 1), {asset: diamond, width: 32, height: 32},
-			new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
+    FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(0, -1), {asset: diamond, width: 32, height: 32},
+      new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
+    FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(0, 1), {asset: diamond, width: 32, height: 32},
+      new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
 
-		// ===== save shit ===== //
+    // ===== save shit ===== //
 
-		FlxG.save.bind('funkin', 'ninjamuffin99');
+    FlxG.save.bind('funkin', 'ninjamuffin99');
 
-		// https://github.com/HaxeFlixel/flixel/pull/2396
-		// IF/WHEN MY PR GOES THRU AND IT GETS INTO MAIN FLIXEL, DELETE THIS CHUNKOF CODE, AND THEN UNCOMMENT THE LINE BELOW
-		// FlxG.sound.loadSavedPrefs();
+    // https://github.com/HaxeFlixel/flixel/pull/2396
+    // IF/WHEN MY PR GOES THRU AND IT GETS INTO MAIN FLIXEL, DELETE THIS CHUNKOF CODE, AND THEN UNCOMMENT THE LINE BELOW
+    // FlxG.sound.loadSavedPrefs();
 
-		if (FlxG.save.data.volume != null)
-			FlxG.sound.volume = FlxG.save.data.volume;
-		if (FlxG.save.data.mute != null)
-			FlxG.sound.muted = FlxG.save.data.mute;
+    if (FlxG.save.data.volume != null)
+      FlxG.sound.volume = FlxG.save.data.volume;
+    if (FlxG.save.data.mute != null)
+      FlxG.sound.muted = FlxG.save.data.mute;
 
-		// Make errors and warnings less annoying.
-		LogStyle.ERROR.openConsole = false;
-		LogStyle.ERROR.errorSound = null;
-		LogStyle.WARNING.openConsole = false;
-		LogStyle.WARNING.errorSound = null;
+    // Make errors and warnings less annoying.
+    LogStyle.ERROR.openConsole = false;
+    LogStyle.ERROR.errorSound = null;
+    LogStyle.WARNING.openConsole = false;
+    LogStyle.WARNING.errorSound = null;
 
-		// FlxG.save.close();
-		// FlxG.sound.loadSavedPrefs();
-		WindowUtil.initWindowEvents();
+    // FlxG.save.close();
+    // FlxG.sound.loadSavedPrefs();
+    WindowUtil.initWindowEvents();
+    WindowUtil.disableCrashHandler();
 
-		PreferencesMenu.initPrefs();
-		PlayerSettings.init();
-		Highscore.load();
+    PreferencesMenu.initPrefs();
+    PlayerSettings.init();
+    Highscore.load();
 
-		if (FlxG.save.data.weekUnlocked != null)
-		{
-			// FIX LATER!!!
-			// WEEK UNLOCK PROGRESSION!!
-			// StoryMenuState.weekUnlocked = FlxG.save.data.weekUnlocked;
+    if (FlxG.save.data.weekUnlocked != null)
+    {
+      // FIX LATER!!!
+      // WEEK UNLOCK PROGRESSION!!
+      // StoryMenuState.weekUnlocked = FlxG.save.data.weekUnlocked;
 
-			if (StoryMenuState.weekUnlocked.length < 4)
-				StoryMenuState.weekUnlocked.insert(0, true);
+      if (StoryMenuState.weekUnlocked.length < 4)
+        StoryMenuState.weekUnlocked.insert(0, true);
 
-			// QUICK PATCH OOPS!
-			if (!StoryMenuState.weekUnlocked[0])
-				StoryMenuState.weekUnlocked[0] = true;
-		}
+      // QUICK PATCH OOPS!
+      if (!StoryMenuState.weekUnlocked[0])
+        StoryMenuState.weekUnlocked[0] = true;
+    }
 
-		if (FlxG.save.data.seenVideo != null)
-			VideoState.seenVideo = FlxG.save.data.seenVideo;
+    if (FlxG.save.data.seenVideo != null)
+      VideoState.seenVideo = FlxG.save.data.seenVideo;
 
-		// ===== fuck outta here ===== //
+    // ===== fuck outta here ===== //
 
-		// FlxTransitionableState.skipNextTransOut = true;
-		FlxTransitionableState.skipNextTransIn = true;
+    // FlxTransitionableState.skipNextTransOut = true;
+    FlxTransitionableState.skipNextTransIn = true;
 
-		SongEventHandler.registerBaseEventCallbacks();
-		// TODO: Register custom event callbacks here
+    // TODO: Register custom event callbacks here
 
-		SongDataParser.loadSongCache();
-		StageDataParser.loadStageCache();
-		CharacterDataParser.loadCharacterCache();
-		ModuleHandler.buildModuleCallbacks();
-		ModuleHandler.loadModuleCache();
+    SongEventParser.loadEventCache();
+    SongDataParser.loadSongCache();
+    StageDataParser.loadStageCache();
+    CharacterDataParser.loadCharacterCache();
+    ModuleHandler.buildModuleCallbacks();
+    ModuleHandler.loadModuleCache();
 
-		FlxG.debugger.toggleKeys = [F2];
+    FlxG.debugger.toggleKeys = [F2];
 
-		#if song
-		var song = getSong();
+    ModuleHandler.callOnCreate();
 
-		var weeks = [
-			['bopeebo', 'fresh', 'dadbattle'],
-			['spookeez', 'south', 'monster'],
-			['spooky', 'spooky', 'monster'],
-			['pico', 'philly', 'blammed'],
-			['satin-panties', 'high', 'milf'],
-			['cocoa', 'eggnog', 'winter-horrorland'],
-			['senpai', 'roses', 'thorns'],
-			['ugh', 'guns', 'stress']
-		];
+    #if song
+    var song = getSong();
 
-		var week = 0;
-		for (i in 0...weeks.length)
-		{
-			if (weeks[i].contains(song))
-			{
-				week = i + 1;
-				break;
-			}
-		}
+    var weeks = [
+      ['bopeebo', 'fresh', 'dadbattle'],
+      ['spookeez', 'south', 'monster'],
+      ['spooky', 'spooky', 'monster'],
+      ['pico', 'philly', 'blammed'],
+      ['satin-panties', 'high', 'milf'],
+      ['cocoa', 'eggnog', 'winter-horrorland'],
+      ['senpai', 'roses', 'thorns'],
+      ['ugh', 'guns', 'stress']
+    ];
 
-		if (week == 0)
-			throw 'Invalid -D song=$song';
+    var week = 0;
+    for (i in 0...weeks.length)
+    {
+      if (weeks[i].contains(song))
+      {
+        week = i + 1;
+        break;
+      }
+    }
 
-		startSong(week, song, false);
-		#elseif week
-		var week = getWeek();
+    if (week == 0)
+      throw 'Invalid -D song=$song';
 
-		var songs = [
-			      'bopeebo', 'spookeez', 'spooky', 'pico',
-			'satin-panties',    'cocoa', 'senpai',  'ugh'
-		];
+    startSong(week, song, false);
+    #elseif week
+    var week = getWeek();
 
-		if (week <= 0 || week >= songs.length)
-			throw "invalid -D week=" + week;
+    var songs = [
+            'bopeebo', 'spookeez', 'spooky', 'pico',
+      'satin-panties',    'cocoa', 'senpai',  'ugh'
+    ];
 
-		startSong(week, songs[week - 1], true);
-		#elseif FREEPLAY
-		FlxG.switchState(new FreeplayState());
-		#elseif ANIMATE
-		FlxG.switchState(new funkin.animate.dotstuff.DotStuffTestStage());
-		#elseif CHARTING
-		FlxG.switchState(new ChartingState());
-		#elseif STAGEBUILD
-		FlxG.switchState(new StageBuilderState());
-		#elseif FIGHT
-		FlxG.switchState(new PicoFight());
-		#elseif ANIMDEBUG
-		FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
-		#elseif LATENCY
-		FlxG.switchState(new LatencyState());
-		#elseif NETTEST
-		FlxG.switchState(new netTest.NetTest());
-		#else
-		FlxG.sound.cache(Paths.music('freakyMenu'));
-		FlxG.switchState(new TitleState());
-		#end
-	}
+    if (week <= 0 || week >= songs.length)
+      throw "invalid -D week=" + week;
 
-	function startSong(week, song, isStoryMode)
-	{
-		var dif = getDif();
+    startSong(week, songs[week - 1], true);
+    #elseif FREEPLAY
+    FlxG.switchState(new FreeplayState());
+    #elseif ANIMATE
+    FlxG.switchState(new funkin.animate.dotstuff.DotStuffTestStage());
+    #elseif CHARTING
+    FlxG.switchState(new ChartingState());
+    #elseif STAGEBUILD
+    FlxG.switchState(new StageBuilderState());
+    #elseif FIGHT
+    FlxG.switchState(new PicoFight());
+    #elseif ANIMDEBUG
+    FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
+    #elseif LATENCY
+    FlxG.switchState(new LatencyState());
+    #elseif NETTEST
+    FlxG.switchState(new netTest.NetTest());
+    #else
+    FlxG.sound.cache(Paths.music('freakyMenu'));
+    FlxG.switchState(new TitleState());
+    #end
+  }
 
-		PlayState.currentSong = SongLoad.loadFromJson(song, song);
-		PlayState.currentSong_NEW = SongDataParser.fetchSong(song);
-		PlayState.isStoryMode = isStoryMode;
-		PlayState.storyDifficulty = dif;
-		PlayState.storyDifficulty_NEW = switch (dif)
-		{
-			case 0: 'easy';
-			case 1: 'normal';
-			case 2: 'hard';
-			default: 'normal';
-		};
-		SongLoad.curDiff = PlayState.storyDifficulty_NEW;
-		PlayState.storyWeek = week;
-		LoadingState.loadAndSwitchState(new PlayState());
-	}
+  function startSong(week, song, isStoryMode)
+  {
+    var dif = getDif();
+
+    PlayState.currentSong = SongLoad.loadFromJson(song, song);
+    PlayState.currentSong_NEW = SongDataParser.fetchSong(song);
+    PlayState.isStoryMode = isStoryMode;
+    PlayState.storyDifficulty = dif;
+    PlayState.storyDifficulty_NEW = switch (dif)
+    {
+      case 0: 'easy';
+      case 1: 'normal';
+      case 2: 'hard';
+      default: 'normal';
+    };
+    SongLoad.curDiff = PlayState.storyDifficulty_NEW;
+    PlayState.storyWeek = week;
+    LoadingState.loadAndSwitchState(new PlayState());
+  }
 }
 
 function getWeek()
-	return Std.parseInt(MacroUtil.getDefine("week"));
+  return Std.parseInt(MacroUtil.getDefine("week"));
 
 function getSong()
-	return MacroUtil.getDefine("song");
+  return MacroUtil.getDefine("song");
 
 function getDif()
-	return Std.parseInt(MacroUtil.getDefine("dif", "1"));
+  return Std.parseInt(MacroUtil.getDefine("dif", "1"));
diff --git a/source/funkin/freeplayStuff/DJBoyfriend.hx b/source/funkin/freeplayStuff/DJBoyfriend.hx
index 2e0954563..a4afb9753 100644
--- a/source/funkin/freeplayStuff/DJBoyfriend.hx
+++ b/source/funkin/freeplayStuff/DJBoyfriend.hx
@@ -6,178 +6,178 @@ import funkin.util.assets.FlxAnimationUtil;
 
 class DJBoyfriend extends FlxSprite
 {
-	// Represents the sprite's current status.
-	// Without state machines I would have driven myself crazy years ago.
-	public var currentState:DJBoyfriendState = Intro;
+  // Represents the sprite's current status.
+  // Without state machines I would have driven myself crazy years ago.
+  public var currentState:DJBoyfriendState = Intro;
 
-	// A callback activated when the intro animation finishes.
-	public var onIntroDone:FlxSignal = new FlxSignal();
+  // A callback activated when the intro animation finishes.
+  public var onIntroDone:FlxSignal = new FlxSignal();
 
-	// A callback activated when Boyfriend gets spooked.
-	public var onSpook:FlxSignal = new FlxSignal();
+  // A callback activated when Boyfriend gets spooked.
+  public var onSpook:FlxSignal = new FlxSignal();
 
-	// playAnim stolen from Character.hx, cuz im lazy lol!
-	// TODO: Switch this class to use SwagSprite instead.
-	public var animOffsets:Map<String, Array<Dynamic>>;
+  // playAnim stolen from Character.hx, cuz im lazy lol!
+  // TODO: Switch this class to use SwagSprite instead.
+  public var animOffsets:Map<String, Array<Dynamic>>;
 
-	static final SPOOK_PERIOD:Float = 180.0;
+  static final SPOOK_PERIOD:Float = 180.0;
 
-	// Time since dad last SPOOKED you.
-	var timeSinceSpook:Float = 0;
+  // Time since dad last SPOOKED you.
+  var timeSinceSpook:Float = 0;
 
-	public function new(x:Float, y:Float)
-	{
-		super(x, y);
+  public function new(x:Float, y:Float)
+  {
+    super(x, y);
 
-		animOffsets = new Map<String, Array<Dynamic>>();
+    animOffsets = new Map<String, Array<Dynamic>>();
 
-		setupAnimations();
+    setupAnimations();
 
-		animation.finishCallback = onFinishAnim;
-	}
+    animation.finishCallback = onFinishAnim;
+  }
 
-	public override function update(elapsed:Float):Void
-	{
-		super.update(elapsed);
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
 
-		if (FlxG.keys.justPressed.LEFT)
-		{
-			animOffsets["confirm"] = [animOffsets["confirm"][0] + 1, animOffsets["confirm"][1]];
-			applyAnimOffset();
-		}
-		else if (FlxG.keys.justPressed.RIGHT)
-		{
-			animOffsets["confirm"] = [animOffsets["confirm"][0] - 1, animOffsets["confirm"][1]];
-			applyAnimOffset();
-		}
-		else if (FlxG.keys.justPressed.UP)
-		{
-			animOffsets["confirm"] = [animOffsets["confirm"][0], animOffsets["confirm"][1] + 1];
-			applyAnimOffset();
-		}
-		else if (FlxG.keys.justPressed.DOWN)
-		{
-			animOffsets["confirm"] = [animOffsets["confirm"][0], animOffsets["confirm"][1] - 1];
-			applyAnimOffset();
-		}
+    if (FlxG.keys.justPressed.LEFT)
+    {
+      animOffsets["confirm"] = [animOffsets["confirm"][0] + 1, animOffsets["confirm"][1]];
+      applyAnimOffset();
+    }
+    else if (FlxG.keys.justPressed.RIGHT)
+    {
+      animOffsets["confirm"] = [animOffsets["confirm"][0] - 1, animOffsets["confirm"][1]];
+      applyAnimOffset();
+    }
+    else if (FlxG.keys.justPressed.UP)
+    {
+      animOffsets["confirm"] = [animOffsets["confirm"][0], animOffsets["confirm"][1] + 1];
+      applyAnimOffset();
+    }
+    else if (FlxG.keys.justPressed.DOWN)
+    {
+      animOffsets["confirm"] = [animOffsets["confirm"][0], animOffsets["confirm"][1] - 1];
+      applyAnimOffset();
+    }
 
-		switch (currentState)
-		{
-			case Intro:
-				// Play the intro animation then leave this state immediately.
-				if (getCurrentAnimation() != 'intro')
-					playAnimation('intro', true);
-				timeSinceSpook = 0;
-			case Idle:
-				// We are in this state the majority of the time.
-				if (getCurrentAnimation() != 'idle' || animation.finished)
-				{
-					if (timeSinceSpook > SPOOK_PERIOD)
-					{
-						currentState = Spook;
-					}
-					else
-					{
-						playAnimation('idle', false);
-					}
-				}
-				timeSinceSpook += elapsed;
-			case Confirm:
-				if (getCurrentAnimation() != 'confirm')
-					playAnimation('confirm', false);
-				timeSinceSpook = 0;
-			case Spook:
-				if (getCurrentAnimation() != 'spook')
-				{
-					onSpook.dispatch();
-					playAnimation('spook', false);
-				}
-				timeSinceSpook = 0;
-			default:
-				// I shit myself.
-		}
-	}
+    switch (currentState)
+    {
+      case Intro:
+        // Play the intro animation then leave this state immediately.
+        if (getCurrentAnimation() != 'intro')
+          playAnimation('intro', true);
+        timeSinceSpook = 0;
+      case Idle:
+        // We are in this state the majority of the time.
+        if (getCurrentAnimation() != 'idle' || animation.finished)
+        {
+          if (timeSinceSpook > SPOOK_PERIOD)
+          {
+            currentState = Spook;
+          }
+          else
+          {
+            playAnimation('idle', false);
+          }
+        }
+        timeSinceSpook += elapsed;
+      case Confirm:
+        if (getCurrentAnimation() != 'confirm')
+          playAnimation('confirm', false);
+        timeSinceSpook = 0;
+      case Spook:
+        if (getCurrentAnimation() != 'spook')
+        {
+          onSpook.dispatch();
+          playAnimation('spook', false);
+        }
+        timeSinceSpook = 0;
+      default:
+        // I shit myself.
+    }
+  }
 
-	function onFinishAnim(name:String):Void
-	{
-		switch (name)
-		{
-			case "intro":
-				trace('Finished intro');
-				currentState = Idle;
-				onIntroDone.dispatch();
-			case "idle":
-				trace('Finished idle');
-			case "spook":
-				trace('Finished spook');
-				currentState = Idle;
-			case "confirm":
-				trace('Finished confirm');
-		}
-	}
+  function onFinishAnim(name:String):Void
+  {
+    switch (name)
+    {
+      case "intro":
+        // trace('Finished intro');
+        currentState = Idle;
+        onIntroDone.dispatch();
+      case "idle":
+      // trace('Finished idle');
+      case "spook":
+        // trace('Finished spook');
+        currentState = Idle;
+      case "confirm":
+        // trace('Finished confirm');
+    }
+  }
 
-	public function resetAFKTimer():Void
-	{
-		timeSinceSpook = 0;
-	}
+  public function resetAFKTimer():Void
+  {
+    timeSinceSpook = 0;
+  }
 
-	function setupAnimations():Void
-	{
-		frames = FlxAnimationUtil.combineFramesCollections(Paths.getSparrowAtlas('freeplay/bfFreeplay'), Paths.getSparrowAtlas('freeplay/bf-freeplay-afk'));
+  function setupAnimations():Void
+  {
+    frames = FlxAnimationUtil.combineFramesCollections(Paths.getSparrowAtlas('freeplay/bfFreeplay'), Paths.getSparrowAtlas('freeplay/bf-freeplay-afk'));
 
-		animation.addByPrefix('intro', "boyfriend dj intro", 24, false);
-		addOffset('intro', 0, 0);
+    animation.addByPrefix('intro', "boyfriend dj intro", 24, false);
+    addOffset('intro', 0, 0);
 
-		animation.addByPrefix('idle', "Boyfriend DJ0", 24, false);
-		addOffset('idle', -4, -426);
+    animation.addByPrefix('idle', "Boyfriend DJ0", 24, false);
+    addOffset('idle', -4, -426);
 
-		animation.addByPrefix('confirm', "Boyfriend DJ confirm", 24, false);
-		addOffset('confirm', 40, -451);
+    animation.addByPrefix('confirm', "Boyfriend DJ confirm", 24, false);
+    addOffset('confirm', 40, -451);
 
-		animation.addByPrefix('spook', "bf dj afk0", 24, false);
-		addOffset('spook', -3, -272);
-	}
+    animation.addByPrefix('spook', "bf dj afk0", 24, false);
+    addOffset('spook', -3, -272);
+  }
 
-	public function confirm():Void
-	{
-		currentState = Confirm;
-	}
+  public function confirm():Void
+  {
+    currentState = Confirm;
+  }
 
-	public inline function addOffset(name:String, x:Float = 0, y:Float = 0)
-	{
-		animOffsets[name] = [x, y];
-	}
+  public inline function addOffset(name:String, x:Float = 0, y:Float = 0)
+  {
+    animOffsets[name] = [x, y];
+  }
 
-	public function getCurrentAnimation():String
-	{
-		if (this.animation == null || this.animation.curAnim == null)
-			return "";
-		return this.animation.curAnim.name;
-	}
+  public function getCurrentAnimation():String
+  {
+    if (this.animation == null || this.animation.curAnim == null)
+      return "";
+    return this.animation.curAnim.name;
+  }
 
-	public function playAnimation(AnimName:String, Force:Bool = false, Reversed:Bool = false, Frame:Int = 0):Void
-	{
-		animation.play(AnimName, Force, Reversed, Frame);
-		applyAnimOffset();
-	}
+  public function playAnimation(AnimName:String, Force:Bool = false, Reversed:Bool = false, Frame:Int = 0):Void
+  {
+    animation.play(AnimName, Force, Reversed, Frame);
+    applyAnimOffset();
+  }
 
-	function applyAnimOffset()
-	{
-		var AnimName = getCurrentAnimation();
-		var daOffset = animOffsets.get(AnimName);
-		if (animOffsets.exists(AnimName))
-		{
-			offset.set(daOffset[0], daOffset[1]);
-		}
-		else
-			offset.set(0, 0);
-	}
+  function applyAnimOffset()
+  {
+    var AnimName = getCurrentAnimation();
+    var daOffset = animOffsets.get(AnimName);
+    if (animOffsets.exists(AnimName))
+    {
+      offset.set(daOffset[0], daOffset[1]);
+    }
+    else
+      offset.set(0, 0);
+  }
 }
 
 enum DJBoyfriendState
 {
-	Intro;
-	Idle;
-	Confirm;
-	Spook;
+  Intro;
+  Idle;
+  Confirm;
+  Spook;
 }
diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index 35cd7b869..56b6aa4d3 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -6,8 +6,7 @@ import flixel.FlxG; // This one in particular causes a compile error if you're u
 // These are great.
 using Lambda;
 using StringTools;
-
+using funkin.util.tools.MapTools;
 using funkin.util.tools.IteratorTools;
 using funkin.util.tools.StringTools;
-
 #end
diff --git a/source/funkin/modding/IScriptedClass.hx b/source/funkin/modding/IScriptedClass.hx
index b6eca7e68..fe2e983b3 100644
--- a/source/funkin/modding/IScriptedClass.hx
+++ b/source/funkin/modding/IScriptedClass.hx
@@ -60,6 +60,7 @@ interface IPlayStateScriptedClass extends IScriptedClass
 	 * and can be cancelled by scripts.
 	 */
 	public function onPause(event:PauseScriptEvent):Void;
+
 	/**
 	 * Called when the game is unpaused.
 	 */
@@ -70,18 +71,22 @@ interface IPlayStateScriptedClass extends IScriptedClass
 	 * Use this to mutate the chart.
 	 */
 	public function onSongLoaded(event:SongLoadScriptEvent):Void;
+
 	/**
 	 * Called when the song starts (conductor time is 0 seconds).
 	 */
 	public function onSongStart(event:ScriptEvent):Void;
+
 	/**
 	 * Called when the song ends and the song is about to be unloaded.
 	 */
 	public function onSongEnd(event:ScriptEvent):Void;
+
 	/**
 	 * Called as the player runs out of health just before the game over substate is entered.
 	 */
 	public function onGameOver(event:ScriptEvent):Void;
+
 	/**
 	 * Called when the player restarts the song, either via pause menu or restarting after a game over.
 	 */
@@ -92,19 +97,27 @@ interface IPlayStateScriptedClass extends IScriptedClass
 	 * Query the note attached to the event to determine if it was hit by the player or CPU.
 	 */
 	public function onNoteHit(event:NoteScriptEvent):Void;
+
 	/**
 	 * Called when EITHER player (usually the player) misses a note.
 	 */
 	public function onNoteMiss(event:NoteScriptEvent):Void;
+
 	/**
 	 * Called when the player presses a key when no note is on the strumline.
 	 */
 	public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void;
 
+	/**
+	 * Called when the song reaches an event.
+	 */
+	public function onSongEvent(event:SongEventScriptEvent):Void;
+
 	/**
 	 * Called once every step of the song.
 	 */
 	public function onStepHit(event:SongTimeScriptEvent):Void;
+
 	/**
 	 * Called once every beat of the song.
 	 */
@@ -114,10 +127,12 @@ interface IPlayStateScriptedClass extends IScriptedClass
 	 * Called when the countdown of the song starts.
 	 */
 	public function onCountdownStart(event:CountdownScriptEvent):Void;
+
 	/**
 	 * Called when the a part of the countdown happens.
 	 */
 	public function onCountdownStep(event:CountdownScriptEvent):Void;
+
 	/**
 	 * Called when the countdown of the song ends.
 	 */
diff --git a/source/funkin/modding/PolymodErrorHandler.hx b/source/funkin/modding/PolymodErrorHandler.hx
index c0616dfb5..70a716821 100644
--- a/source/funkin/modding/PolymodErrorHandler.hx
+++ b/source/funkin/modding/PolymodErrorHandler.hx
@@ -4,68 +4,88 @@ import polymod.Polymod;
 
 class PolymodErrorHandler
 {
-	/**
-	 * Show a popup with the given text.
-	 * This displays a system popup, it WILL interrupt the game.
-	 * Make sure to only use this when it's important, like when there's a script error.
-	 * 
-	 * @param name The name at the top of the popup.
-	 * @param desc The body text of the popup.
-	 */
-	public static function showAlert(name:String, desc:String):Void
-	{
-		lime.app.Application.current.window.alert(desc, name);
-	}
+  /**
+   * Show a popup with the given text.
+   * This displays a system popup, it WILL interrupt the game.
+   * Make sure to only use this when it's important, like when there's a script error.
+   * 
+   * @param name The name at the top of the popup.
+   * @param desc The body text of the popup.
+   */
+  public static function showAlert(name:String, desc:String):Void
+  {
+    lime.app.Application.current.window.alert(desc, name);
+  }
 
-	public static function onPolymodError(error:PolymodError):Void
-	{
-		// Perform an action based on the error code.
-		switch (error.code)
-		{
-			case MOD_LOAD_PREPARE:
-				logInfo('[POLYMOD]: ${error.message}');
-			case MOD_LOAD_DONE:
-				logInfo('[POLYMOD]: ${error.message}');
-			case MISSING_ICON:
-				logWarn('[POLYMOD]: A mod is missing an icon. Please add one.');
-			case SCRIPT_PARSE_ERROR:
-				// A syntax error when parsing a script.
-				logError('[POLYMOD]: ${error.message}');
-				showAlert('Polymod Script Parsing Error', error.message);
-			case SCRIPT_EXCEPTION:
-				// A runtime error when running a script.
-				logError('[POLYMOD]: ${error.message}');
-				showAlert('Polymod Script Execution Error', error.message);
-			case SCRIPT_CLASS_NOT_FOUND:
-				// A scripted class tried to reference an unknown superclass.
-				logError('[POLYMOD]: ${error.message}');
-				showAlert('Polymod Script Parsing Error', error.message);
-			default:
-				// Log the message based on its severity.
-				switch (error.severity)
-				{
-					case NOTICE:
-						logInfo('[POLYMOD]: ${error.message}');
-					case WARNING:
-						logWarn('[POLYMOD]: ${error.message}');
-					case ERROR:
-						logError('[POLYMOD]: ${error.message}');
-				}
-		}
-	}
+  public static function onPolymodError(error:PolymodError):Void
+  {
+    // Perform an action based on the error code.
+    switch (error.code)
+    {
+      case FRAMEWORK_INIT, FRAMEWORK_AUTODETECT, SCRIPT_PARSING:
+        // Unimportant.
+        return;
 
-	static function logInfo(message:String):Void
-	{
-		trace('[INFO ] ${message}');
-	}
+      case MOD_LOAD_PREPARE, MOD_LOAD_DONE:
+        logInfo('LOADING MOD - ${error.message}');
 
-	static function logError(message:String):Void
-	{
-		trace('[ERROR] ${message}');
-	}
+      case MISSING_ICON:
+        logWarn('A mod is missing an icon. Please add one.');
 
-	static function logWarn(message:String):Void
-	{
-		trace('[WARN ] ${message}');
-	}
+      case SCRIPT_PARSE_ERROR:
+        // A syntax error when parsing a script.
+        logError(error.message);
+        // Notify the user via popup.
+        showAlert('Polymod Script Parsing Error', error.message);
+      case SCRIPT_RUNTIME_EXCEPTION:
+        // A runtime error when running a script.
+        logError(error.message);
+        // Notify the user via popup.
+        showAlert('Polymod Script Exception', error.message);
+      case SCRIPT_CLASS_MODULE_NOT_FOUND:
+        // A scripted class tried to reference an unknown class or module.
+        logError(error.message);
+
+        // Last word is the class name.
+        var className:String = error.message.split(' ').pop();
+        var msg:String = 'Import error in ${error.origin}';
+        msg += '\nCould not import unknown class ${className}';
+        msg += '\nCheck to ensure the class exists and is spelled correctly.';
+
+        // Notify the user via popup.
+        showAlert('Polymod Script Import Error', msg);
+      case SCRIPT_CLASS_MODULE_BLACKLISTED:
+        // A scripted class tried to reference a blacklisted class or module.
+        logError(error.message);
+        // Notify the user via popup.
+        showAlert('Polymod Script Blacklist Violation', error.message);
+
+      default:
+        // Log the message based on its severity.
+        switch (error.severity)
+        {
+          case NOTICE:
+            logInfo(error.message);
+          case WARNING:
+            logWarn(error.message);
+          case ERROR:
+            logError(error.message);
+        }
+    }
+  }
+
+  static function logInfo(message:String):Void
+  {
+    trace('[INFO-] ${message}');
+  }
+
+  static function logError(message:String):Void
+  {
+    trace('[ERROR] ${message}');
+  }
+
+  static function logWarn(message:String):Void
+  {
+    trace('[WARN-] ${message}');
+  }
 }
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index 9ec5a968f..9586d6f46 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -1,5 +1,6 @@
 package funkin.modding;
 
+import funkin.util.macro.ClassMacro;
 import funkin.modding.module.ModuleHandler;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.song.SongData;
@@ -11,251 +12,271 @@ import funkin.util.FileUtil;
 
 class PolymodHandler
 {
-	/**
-	 * The API version that mods should comply with.
-	 	 * Format this with Semantic Versioning; <MAJOR>.<MINOR>.<PATCH>.
-	 * Bug fixes increment the patch version, new features increment the minor version.
-	 * Changes that break old mods increment the major version.
-	 */
-	static final API_VERSION = "0.1.0";
+  /**
+   * The API version that mods should comply with.
+      * Format this with Semantic Versioning; <MAJOR>.<MINOR>.<PATCH>.
+   * Bug fixes increment the patch version, new features increment the minor version.
+   * Changes that break old mods increment the major version.
+   */
+  static final API_VERSION = "0.1.0";
 
-	/**
-	 * Where relative to the executable that mods are located.
-	 */
-	static final MOD_FOLDER = "mods";
+  /**
+   * Where relative to the executable that mods are located.
+   */
+  static final MOD_FOLDER = "mods";
 
-	public static function createModRoot()
-	{
-		FileUtil.createDirIfNotExists(MOD_FOLDER);
-	}
+  public static function createModRoot()
+  {
+    FileUtil.createDirIfNotExists(MOD_FOLDER);
+  }
 
-	/**
-	 * Loads the game with ALL mods enabled with Polymod.
-	 */
-	public static function loadAllMods()
-	{
-		// Create the mod root if it doesn't exist.
-		createModRoot();
-		trace("Initializing Polymod (using all mods)...");
-		loadModsById(getAllModIds());
-	}
+  /**
+   * Loads the game with ALL mods enabled with Polymod.
+   */
+  public static function loadAllMods()
+  {
+    // Create the mod root if it doesn't exist.
+    createModRoot();
+    trace("Initializing Polymod (using all mods)...");
+    loadModsById(getAllModIds());
+  }
 
-	/**
-	 * Loads the game with configured mods enabled with Polymod.
-	 */
-	public static function loadEnabledMods()
-	{
-		// Create the mod root if it doesn't exist.
-		createModRoot();
+  /**
+   * Loads the game with configured mods enabled with Polymod.
+   */
+  public static function loadEnabledMods()
+  {
+    // Create the mod root if it doesn't exist.
+    createModRoot();
 
-		trace("Initializing Polymod (using configured mods)...");
-		loadModsById(getEnabledModIds());
-	}
+    trace("Initializing Polymod (using configured mods)...");
+    loadModsById(getEnabledModIds());
+  }
 
-	/**
-	 * Loads the game without any mods enabled with Polymod.
-	 */
-	public static function loadNoMods()
-	{
-		// Create the mod root if it doesn't exist.
-		createModRoot();
+  /**
+   * Loads the game without any mods enabled with Polymod.
+   */
+  public static function loadNoMods()
+  {
+    // Create the mod root if it doesn't exist.
+    createModRoot();
 
-		// We still need to configure the debug print calls etc.
-		trace("Initializing Polymod (using no mods)...");
-		loadModsById([]);
-	}
+    // We still need to configure the debug print calls etc.
+    trace("Initializing Polymod (using no mods)...");
+    loadModsById([]);
+  }
 
-	public static function loadModsById(ids:Array<String>)
-	{
-		if (ids.length == 0)
-		{
-			trace('You attempted to load zero mods.');
-		}
-		else
-		{
-			trace('Attempting to load ${ids.length} mods...');
-		}
-		var loadedModList = polymod.Polymod.init({
-			// Root directory for all mods.
-			modRoot: MOD_FOLDER,
-			// The directories for one or more mods to load.
-			dirs: ids,
-			// Framework being used to load assets.
-			framework: OPENFL,
-			// The current version of our API.
-			apiVersionRule: API_VERSION,
-			// Call this function any time an error occurs.
-			errorCallback: PolymodErrorHandler.onPolymodError,
-			// Enforce semantic version patterns for each mod.
-			// modVersions: null,
-			// A map telling Polymod what the asset type is for unfamiliar file extensions.
-			// extensionMap: [],
+  public static function loadModsById(ids:Array<String>)
+  {
+    if (ids.length == 0)
+    {
+      trace('You attempted to load zero mods.');
+    }
+    else
+    {
+      trace('Attempting to load ${ids.length} mods...');
+    }
 
-			frameworkParams: buildFrameworkParams(),
+    buildImports();
 
-			// List of filenames to ignore in mods. Use the default list to ignore the metadata file, etc.
-			ignoredFiles: Polymod.getDefaultIgnoreList(),
+    var loadedModList = polymod.Polymod.init({
+      // Root directory for all mods.
+      modRoot: MOD_FOLDER,
+      // The directories for one or more mods to load.
+      dirs: ids,
+      // Framework being used to load assets.
+      framework: OPENFL,
+      // The current version of our API.
+      apiVersionRule: API_VERSION,
+      // Call this function any time an error occurs.
+      errorCallback: PolymodErrorHandler.onPolymodError,
+      // Enforce semantic version patterns for each mod.
+      // modVersions: null,
+      // A map telling Polymod what the asset type is for unfamiliar file extensions.
+      // extensionMap: [],
 
-			// Parsing rules for various data formats.
-			parseRules: buildParseRules(),
+      frameworkParams: buildFrameworkParams(),
 
-			// Parse hxc files and register the scripted classes in them.
-			useScriptedClasses: true,
-		});
+      // List of filenames to ignore in mods. Use the default list to ignore the metadata file, etc.
+      ignoredFiles: Polymod.getDefaultIgnoreList(),
 
-		if (loadedModList == null)
-		{
-			trace('[POLYMOD] An error occurred! Failed when loading mods!');
-		}
-		else
-		{
-			if (loadedModList.length == 0)
-			{
-				trace('[POLYMOD] Mod loading complete. We loaded no mods / ${ids.length} mods.');
-			}
-			else
-			{
-				trace('[POLYMOD] Mod loading complete. We loaded ${loadedModList.length} / ${ids.length} mods.');
-			}
-		}
+      // Parsing rules for various data formats.
+      parseRules: buildParseRules(),
 
-		for (mod in loadedModList)
-		{
-			trace('  * ${mod.title} v${mod.modVersion} [${mod.id}]');
-		}
+      // Parse hxc files and register the scripted classes in them.
+      useScriptedClasses: true,
+    });
 
-		#if debug
-		var fileList = Polymod.listModFiles(PolymodAssetType.IMAGE);
-		trace('[POLYMOD] Installed mods have replaced ${fileList.length} images.');
-		for (item in fileList)
-			trace('  * $item');
+    if (loadedModList == null)
+    {
+      trace('An error occurred! Failed when loading mods!');
+    }
+    else
+    {
+      if (loadedModList.length == 0)
+      {
+        trace('Mod loading complete. We loaded no mods / ${ids.length} mods.');
+      }
+      else
+      {
+        trace('Mod loading complete. We loaded ${loadedModList.length} / ${ids.length} mods.');
+      }
+    }
 
-		fileList = Polymod.listModFiles(PolymodAssetType.TEXT);
-		trace('[POLYMOD] Installed mods have replaced ${fileList.length} text files.');
-		for (item in fileList)
-			trace('  * $item');
+    for (mod in loadedModList)
+    {
+      trace('  * ${mod.title} v${mod.modVersion} [${mod.id}]');
+    }
 
-		fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_MUSIC);
-		trace('[POLYMOD] Installed mods have replaced ${fileList.length} music files.');
-		for (item in fileList)
-			trace('  * $item');
+    #if debug
+    var fileList = Polymod.listModFiles(PolymodAssetType.IMAGE);
+    trace('Installed mods have replaced ${fileList.length} images.');
+    for (item in fileList)
+      trace('  * $item');
 
-		fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_SOUND);
-		trace('[POLYMOD] Installed mods have replaced ${fileList.length} sound files.');
-		for (item in fileList)
-			trace('  * $item');
+    fileList = Polymod.listModFiles(PolymodAssetType.TEXT);
+    trace('Installed mods have added/replaced ${fileList.length} text files.');
+    for (item in fileList)
+      trace('  * $item');
 
-		fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_GENERIC);
-		trace('[POLYMOD] Installed mods have replaced ${fileList.length} generic audio files.');
-		for (item in fileList)
-			trace('  * $item');
-		#end
-	}
+    fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_MUSIC);
+    trace('Installed mods have replaced ${fileList.length} music files.');
+    for (item in fileList)
+      trace('  * $item');
 
-	static function buildParseRules():polymod.format.ParseRules
-	{
-		var output = polymod.format.ParseRules.getDefault();
-		// Ensure TXT files have merge support.
-		output.addType("txt", TextFileFormat.LINES);
-		// Ensure script files have merge support.
-		output.addType("hscript", TextFileFormat.PLAINTEXT);
-		output.addType("hxs", TextFileFormat.PLAINTEXT);
-		output.addType("hxc", TextFileFormat.PLAINTEXT);
-		output.addType("hx", TextFileFormat.PLAINTEXT);
+    fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_SOUND);
+    trace('Installed mods have replaced ${fileList.length} sound files.');
+    for (item in fileList)
+      trace('  * $item');
 
-		// You can specify the format of a specific file, with file extension.
-		// output.addFile("data/introText.txt", TextFileFormat.LINES)
-		return output;
-	}
+    fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_GENERIC);
+    trace('Installed mods have replaced ${fileList.length} generic audio files.');
+    for (item in fileList)
+      trace('  * $item');
+    #end
+  }
 
-	static inline function buildFrameworkParams():polymod.Polymod.FrameworkParams
-	{
-		return {
-			assetLibraryPaths: [
-				"songs" => "songs",     "shared" => "", "tutorial" => "tutorial", "scripts" => "scripts", "week1" => "week1",      "week2" => "week2",
-				"week3" => "week3", "week4" => "week4",       "week5" => "week5",     "week6" => "week6", "week7" => "week7", "weekend1" => "weekend1",
-			]
-		}
-	}
+  static function buildImports():Void
+  {
+    // Add default imports for common classes.
 
-	public static function getAllMods():Array<ModMetadata>
-	{
-		trace('Scanning the mods folder...');
-		var modMetadata = Polymod.scan({
-			modRoot: MOD_FOLDER,
-			apiVersionRule: API_VERSION,
-			errorCallback: PolymodErrorHandler.onPolymodError
-		});
-		trace('Found ${modMetadata.length} mods when scanning.');
-		return modMetadata;
-	}
+    // Add import aliases for certain classes.
+    // NOTE: Scripted classes are automatically aliased to their parent class.
+    Polymod.addImportAlias('flixel.math.FlxPoint', flixel.math.FlxPoint.FlxBasePoint);
 
-	public static function getAllModIds():Array<String>
-	{
-		var modIds = [for (i in getAllMods()) i.id];
-		return modIds;
-	}
+    // Add blacklisting for prohibited classes and packages.
+    // `polymod.*`
+    for (cls in ClassMacro.listClassesInPackage('polymod'))
+    {
+      var className = Type.getClassName(cls);
+      Polymod.blacklistImport(className);
+    }
+  }
 
-	public static function setEnabledMods(newModList:Array<String>):Void
-	{
-		FlxG.save.data.enabledMods = newModList;
-		// Make sure to COMMIT the changes.
-		FlxG.save.flush();
-	}
+  static function buildParseRules():polymod.format.ParseRules
+  {
+    var output = polymod.format.ParseRules.getDefault();
+    // Ensure TXT files have merge support.
+    output.addType("txt", TextFileFormat.LINES);
+    // Ensure script files have merge support.
+    output.addType("hscript", TextFileFormat.PLAINTEXT);
+    output.addType("hxs", TextFileFormat.PLAINTEXT);
+    output.addType("hxc", TextFileFormat.PLAINTEXT);
+    output.addType("hx", TextFileFormat.PLAINTEXT);
 
-	/**
-	 * Returns the list of enabled mods.
-	 * @return Array<String>
-	 */
-	public static function getEnabledModIds():Array<String>
-	{
-		if (FlxG.save.data.enabledMods == null)
-		{
-			// NOTE: If the value is null, the enabled mod list is unconfigured.
-			// Currently, we default to disabling newly installed mods.
-			// If we want to auto-enable new mods, but otherwise leave the configured list in place,
-			// we will need some custom logic.
-			FlxG.save.data.enabledMods = [];
-		}
-		return FlxG.save.data.enabledMods;
-	}
+    // You can specify the format of a specific file, with file extension.
+    // output.addFile("data/introText.txt", TextFileFormat.LINES)
+    return output;
+  }
 
-	public static function getEnabledMods():Array<ModMetadata>
-	{
-		var modIds = getEnabledModIds();
-		var modMetadata = getAllMods();
-		var enabledMods = [];
-		for (item in modMetadata)
-		{
-			if (modIds.indexOf(item.id) != -1)
-			{
-				enabledMods.push(item);
-			}
-		}
-		return enabledMods;
-	}
+  static inline function buildFrameworkParams():polymod.Polymod.FrameworkParams
+  {
+    return {
+      assetLibraryPaths: [
+        "songs" => "songs",     "shared" => "", "tutorial" => "tutorial", "scripts" => "scripts", "week1" => "week1",      "week2" => "week2",
+        "week3" => "week3", "week4" => "week4",       "week5" => "week5",     "week6" => "week6", "week7" => "week7", "weekend1" => "weekend1",
+      ]
+    }
+  }
 
-	public static function forceReloadAssets()
-	{
-		// Forcibly clear scripts so that scripts can be edited.
-		ModuleHandler.clearModuleCache();
-		Polymod.clearScripts();
+  public static function getAllMods():Array<ModMetadata>
+  {
+    trace('Scanning the mods folder...');
+    var modMetadata = Polymod.scan({
+      modRoot: MOD_FOLDER,
+      apiVersionRule: API_VERSION,
+      errorCallback: PolymodErrorHandler.onPolymodError
+    });
+    trace('Found ${modMetadata.length} mods when scanning.');
+    return modMetadata;
+  }
 
-		// Forcibly reload Polymod so it finds any new files.
-		// TODO: Replace this with loadEnabledMods().
-		funkin.modding.PolymodHandler.loadAllMods();
+  public static function getAllModIds():Array<String>
+  {
+    var modIds = [for (i in getAllMods()) i.id];
+    return modIds;
+  }
 
-		// Reload scripted classes so stages and modules will update.
-		Polymod.registerAllScriptClasses();
+  public static function setEnabledMods(newModList:Array<String>):Void
+  {
+    FlxG.save.data.enabledMods = newModList;
+    // Make sure to COMMIT the changes.
+    FlxG.save.flush();
+  }
 
-		// Reload everything that is cached.
-		// Currently this freezes the game for a second but I guess that's tolerable?
+  /**
+   * Returns the list of enabled mods.
+   * @return Array<String>
+   */
+  public static function getEnabledModIds():Array<String>
+  {
+    if (FlxG.save.data.enabledMods == null)
+    {
+      // NOTE: If the value is null, the enabled mod list is unconfigured.
+      // Currently, we default to disabling newly installed mods.
+      // If we want to auto-enable new mods, but otherwise leave the configured list in place,
+      // we will need some custom logic.
+      FlxG.save.data.enabledMods = [];
+    }
+    return FlxG.save.data.enabledMods;
+  }
 
-		// TODO: Reload event callbacks
+  public static function getEnabledMods():Array<ModMetadata>
+  {
+    var modIds = getEnabledModIds();
+    var modMetadata = getAllMods();
+    var enabledMods = [];
+    for (item in modMetadata)
+    {
+      if (modIds.indexOf(item.id) != -1)
+      {
+        enabledMods.push(item);
+      }
+    }
+    return enabledMods;
+  }
 
-		SongDataParser.loadSongCache();
-		StageDataParser.loadStageCache();
-		CharacterDataParser.loadCharacterCache();
-		ModuleHandler.loadModuleCache();
-	}
+  public static function forceReloadAssets()
+  {
+    // Forcibly clear scripts so that scripts can be edited.
+    ModuleHandler.clearModuleCache();
+    Polymod.clearScripts();
+
+    // Forcibly reload Polymod so it finds any new files.
+    // TODO: Replace this with loadEnabledMods().
+    funkin.modding.PolymodHandler.loadAllMods();
+
+    // Reload scripted classes so stages and modules will update.
+    Polymod.registerAllScriptClasses();
+
+    // Reload everything that is cached.
+    // Currently this freezes the game for a second but I guess that's tolerable?
+
+    // TODO: Reload event callbacks
+
+    SongDataParser.loadSongCache();
+    StageDataParser.loadStageCache();
+    CharacterDataParser.loadCharacterCache();
+    ModuleHandler.loadModuleCache();
+  }
 }
diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index 3e4249063..9e31a3032 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -15,269 +15,277 @@ typedef ScriptEventType = EventType<ScriptEvent>;
  */
 class ScriptEvent
 {
-	/**
-	 * Called when the relevant object is created.
-	 * Keep in mind that the constructor may be called before the object is needed,
-	 * for the purposes of caching data or otherwise.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final CREATE:ScriptEventType = "CREATE";
+  /**
+   * Called when the relevant object is created.
+   * Keep in mind that the constructor may be called before the object is needed,
+   * for the purposes of caching data or otherwise.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final CREATE:ScriptEventType = "CREATE";
 
-	/**
-	 * Called when the relevant object is destroyed.
-	 * This should perform relevant cleanup to ensure good performance.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final DESTROY:ScriptEventType = "DESTROY";
+  /**
+   * Called when the relevant object is destroyed.
+   * This should perform relevant cleanup to ensure good performance.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final DESTROY:ScriptEventType = "DESTROY";
 
-	/**
-	 * Called during the update function.
-	 * This is called every frame, so be careful!
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final UPDATE:ScriptEventType = "UPDATE";
+  /**
+   * Called during the update function.
+   * This is called every frame, so be careful!
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final UPDATE:ScriptEventType = "UPDATE";
 
-	/**
-	 * Called when the player moves to pause the game.
-	 * 
-	 * This event IS cancelable! Canceling the event will prevent the game from pausing.
-	 */
-	public static inline final PAUSE:ScriptEventType = "PAUSE";
+  /**
+   * Called when the player moves to pause the game.
+   * 
+   * This event IS cancelable! Canceling the event will prevent the game from pausing.
+   */
+  public static inline final PAUSE:ScriptEventType = "PAUSE";
 
-	/**
-	 * Called when the player moves to unpause the game while paused.
-	 * 
-	 * This event IS cancelable! Canceling the event will prevent the game from resuming.
-	 */
-	public static inline final RESUME:ScriptEventType = "RESUME";
+  /**
+   * Called when the player moves to unpause the game while paused.
+   * 
+   * This event IS cancelable! Canceling the event will prevent the game from resuming.
+   */
+  public static inline final RESUME:ScriptEventType = "RESUME";
 
-	/**
-	 * Called once per step in the song. This happens 4 times per measure.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final SONG_BEAT_HIT:ScriptEventType = "BEAT_HIT";
+  /**
+   * Called once per step in the song. This happens 4 times per measure.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final SONG_BEAT_HIT:ScriptEventType = "BEAT_HIT";
 
-	/**
-	 * Called once per step in the song. This happens 16 times per measure.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final SONG_STEP_HIT:ScriptEventType = "STEP_HIT";
+  /**
+   * Called once per step in the song. This happens 16 times per measure.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final SONG_STEP_HIT:ScriptEventType = "STEP_HIT";
 
-	/**
-	 * Called when a character hits a note.
-	 * Important information such as judgement/timing, note data, player/opponent, etc. are all provided.
-	 *
-	 * This event IS cancelable! Canceling this event prevents the note from being hit,
-	 *   and will likely result in a miss later.
-	 */
-	public static inline final NOTE_HIT:ScriptEventType = "NOTE_HIT";
+  /**
+   * Called when a character hits a note.
+   * Important information such as judgement/timing, note data, player/opponent, etc. are all provided.
+   *
+   * This event IS cancelable! Canceling this event prevents the note from being hit,
+   *   and will likely result in a miss later.
+   */
+  public static inline final NOTE_HIT:ScriptEventType = "NOTE_HIT";
 
-	/**
-	 * Called when a character misses a note.
-	 * Important information such as note data, player/opponent, etc. are all provided.
-	 *
-	 * This event IS cancelable! Canceling this event prevents the note from being considered missed,
-	 *   avoiding a combo break and lost health.
-	 */
-	public static inline final NOTE_MISS:ScriptEventType = "NOTE_MISS";
+  /**
+   * Called when a character misses a note.
+   * Important information such as note data, player/opponent, etc. are all provided.
+   *
+   * This event IS cancelable! Canceling this event prevents the note from being considered missed,
+   *   avoiding a combo break and lost health.
+   */
+  public static inline final NOTE_MISS:ScriptEventType = "NOTE_MISS";
 
-	/**
-	 * Called when a character presses a note when there was none there, causing them to lose health.
-	 * Important information such as direction pressed, etc. are all provided.
-	 *
-	 * This event IS cancelable! Canceling this event prevents the note from being considered missed,
-	 *   avoiding lost health/score and preventing the miss animation.
-	 */
-	public static inline final NOTE_GHOST_MISS:ScriptEventType = "NOTE_GHOST_MISS";
+  /**
+   * Called when a character presses a note when there was none there, causing them to lose health.
+   * Important information such as direction pressed, etc. are all provided.
+   *
+   * This event IS cancelable! Canceling this event prevents the note from being considered missed,
+   *   avoiding lost health/score and preventing the miss animation.
+   */
+  public static inline final NOTE_GHOST_MISS:ScriptEventType = "NOTE_GHOST_MISS";
 
-	/**
-	 * Called when the song starts. This occurs as the countdown ends and the instrumental and vocals begin.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final SONG_START:ScriptEventType = "SONG_START";
+  /**
+   * Called when a song event is reached in the chart.
+   * 
+   * This event IS cancelable! Cancelling this event prevents the event from being triggered,
+   *   thus blocking its normal functionality.
+   */
+  public static inline final SONG_EVENT:ScriptEventType = "SONG_EVENT";
 
-	/**
-	 * Called when the song ends. This happens as the instrumental and vocals end.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final SONG_END:ScriptEventType = "SONG_END";
+  /**
+   * Called when the song starts. This occurs as the countdown ends and the instrumental and vocals begin.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final SONG_START:ScriptEventType = "SONG_START";
 
-	/**
-	 * Called when the countdown begins. This occurs before the song starts.
-	 * 
-	 * This event IS cancelable! Canceling this event will prevent the countdown from starting.
-	 * - The song will not start until you call Countdown.performCountdown() later.
-	 * - Note that calling performCountdown() will trigger this event again, so be sure to add logic to ignore it.
-	 */
-	public static inline final COUNTDOWN_START:ScriptEventType = "COUNTDOWN_START";
+  /**
+   * Called when the song ends. This happens as the instrumental and vocals end.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final SONG_END:ScriptEventType = "SONG_END";
 
-	/**
-	 * Called when a step of the countdown happens.
-	 * Includes information about what step of the countdown was hit.
-	 * 
-	 * This event IS cancelable! Canceling this event will pause the countdown.
-	 * - The countdown will not resume until you call PlayState.resumeCountdown().
-	 */
-	public static inline final COUNTDOWN_STEP:ScriptEventType = "COUNTDOWN_STEP";
+  /**
+   * Called when the countdown begins. This occurs before the song starts.
+   * 
+   * This event IS cancelable! Canceling this event will prevent the countdown from starting.
+   * - The song will not start until you call Countdown.performCountdown() later.
+   * - Note that calling performCountdown() will trigger this event again, so be sure to add logic to ignore it.
+   */
+  public static inline final COUNTDOWN_START:ScriptEventType = "COUNTDOWN_START";
 
-	/**
-	 * Called when the countdown is done but just before the song starts.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final COUNTDOWN_END:ScriptEventType = "COUNTDOWN_END";
+  /**
+   * Called when a step of the countdown happens.
+   * Includes information about what step of the countdown was hit.
+   * 
+   * This event IS cancelable! Canceling this event will pause the countdown.
+   * - The countdown will not resume until you call PlayState.resumeCountdown().
+   */
+  public static inline final COUNTDOWN_STEP:ScriptEventType = "COUNTDOWN_STEP";
 
-	/**
-	 * Called before the game over screen triggers and the death animation plays.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final GAME_OVER:ScriptEventType = "GAME_OVER";
+  /**
+   * Called when the countdown is done but just before the song starts.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final COUNTDOWN_END:ScriptEventType = "COUNTDOWN_END";
 
-	/**
-	 * Called after the player presses a key to restart the game.
-	 * This can happen from the pause menu or the game over screen.
-	 * 
-	 * This event IS cancelable! Canceling this event will prevent the game from restarting.
-	 */
-	public static inline final SONG_RETRY:ScriptEventType = "SONG_RETRY";
+  /**
+   * Called before the game over screen triggers and the death animation plays.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final GAME_OVER:ScriptEventType = "GAME_OVER";
 
-	/**
-	 * Called when the player pushes down any key on the keyboard.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final KEY_DOWN:ScriptEventType = "KEY_DOWN";
+  /**
+   * Called after the player presses a key to restart the game.
+   * This can happen from the pause menu or the game over screen.
+   * 
+   * This event IS cancelable! Canceling this event will prevent the game from restarting.
+   */
+  public static inline final SONG_RETRY:ScriptEventType = "SONG_RETRY";
 
-	/**
-	 * Called when the player releases a key on the keyboard.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final KEY_UP:ScriptEventType = "KEY_UP";
+  /**
+   * Called when the player pushes down any key on the keyboard.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final KEY_DOWN:ScriptEventType = "KEY_DOWN";
 
-	/**
-	 * Called when the game has finished loading the notes from JSON.
-	 * This allows modders to mutate the notes before they are used in the song.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final SONG_LOADED:ScriptEventType = "SONG_LOADED";
+  /**
+   * Called when the player releases a key on the keyboard.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final KEY_UP:ScriptEventType = "KEY_UP";
 
-	/**
-	 * Called when the game is about to switch the current FlxState.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final STATE_CHANGE_BEGIN:ScriptEventType = "STATE_CHANGE_BEGIN";
+  /**
+   * Called when the game has finished loading the notes from JSON.
+   * This allows modders to mutate the notes before they are used in the song.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final SONG_LOADED:ScriptEventType = "SONG_LOADED";
 
-	/**
-	 * Called when the game has finished switching the current FlxState.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final STATE_CHANGE_END:ScriptEventType = "STATE_CHANGE_END";
+  /**
+   * Called when the game is about to switch the current FlxState.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final STATE_CHANGE_BEGIN:ScriptEventType = "STATE_CHANGE_BEGIN";
 
-	/**
-	 * Called when the game is about to open a new FlxSubState.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final SUBSTATE_OPEN_BEGIN:ScriptEventType = "SUBSTATE_OPEN_BEGIN";
+  /**
+   * Called when the game has finished switching the current FlxState.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final STATE_CHANGE_END:ScriptEventType = "STATE_CHANGE_END";
 
-	/**
-	 * Called when the game has finished opening a new FlxSubState.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final SUBSTATE_OPEN_END:ScriptEventType = "SUBSTATE_OPEN_END";
+  /**
+   * Called when the game is about to open a new FlxSubState.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final SUBSTATE_OPEN_BEGIN:ScriptEventType = "SUBSTATE_OPEN_BEGIN";
 
-	/**
-	 * Called when the game is about to close the current FlxSubState.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final SUBSTATE_CLOSE_BEGIN:ScriptEventType = "SUBSTATE_CLOSE_BEGIN";
+  /**
+   * Called when the game has finished opening a new FlxSubState.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final SUBSTATE_OPEN_END:ScriptEventType = "SUBSTATE_OPEN_END";
 
-	/**
-	 * Called when the game has finished closing the current FlxSubState.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final SUBSTATE_CLOSE_END:ScriptEventType = "SUBSTATE_CLOSE_END";
+  /**
+   * Called when the game is about to close the current FlxSubState.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final SUBSTATE_CLOSE_BEGIN:ScriptEventType = "SUBSTATE_CLOSE_BEGIN";
 
-	/**
-	 * Called when the game is exiting the current FlxState.
-	 * 
-	 * This event is not cancelable.
-	 */
-	/**
-	 * If true, the behavior associated with this event can be prevented.
-	 * For example, cancelling COUNTDOWN_START should prevent the countdown from starting,
-	 * until another script restarts it, or cancelling NOTE_HIT should cause the note to be missed.
-	 */
-	public var cancelable(default, null):Bool;
+  /**
+   * Called when the game has finished closing the current FlxSubState.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final SUBSTATE_CLOSE_END:ScriptEventType = "SUBSTATE_CLOSE_END";
 
-	/**
-	 * The type associated with the event.
-	 */
-	public var type(default, null):ScriptEventType;
+  /**
+   * Called when the game is exiting the current FlxState.
+   * 
+   * This event is not cancelable.
+   */
+  /**
+   * If true, the behavior associated with this event can be prevented.
+   * For example, cancelling COUNTDOWN_START should prevent the countdown from starting,
+   * until another script restarts it, or cancelling NOTE_HIT should cause the note to be missed.
+   */
+  public var cancelable(default, null):Bool;
 
-	/**
-	 * Whether the event should continue to be triggered on additional targets.
-	 */
-	public var shouldPropagate(default, null):Bool;
+  /**
+   * The type associated with the event.
+   */
+  public var type(default, null):ScriptEventType;
 
-	/**
-	 * Whether the event has been canceled by one of the scripts that received it.
-	 */
-	public var eventCanceled(default, null):Bool;
+  /**
+   * Whether the event should continue to be triggered on additional targets.
+   */
+  public var shouldPropagate(default, null):Bool;
 
-	public function new(type:ScriptEventType, cancelable:Bool = false):Void
-	{
-		this.type = type;
-		this.cancelable = cancelable;
-		this.eventCanceled = false;
-		this.shouldPropagate = true;
-	}
+  /**
+   * Whether the event has been canceled by one of the scripts that received it.
+   */
+  public var eventCanceled(default, null):Bool;
 
-	/**
-	 * Call this function on a cancelable event to cancel the associated behavior.
-	 * For example, cancelling COUNTDOWN_START will prevent the countdown from starting.
-	 */
-	public function cancelEvent():Void
-	{
-		if (cancelable)
-		{
-			eventCanceled = true;
-		}
-	}
+  public function new(type:ScriptEventType, cancelable:Bool = false):Void
+  {
+    this.type = type;
+    this.cancelable = cancelable;
+    this.eventCanceled = false;
+    this.shouldPropagate = true;
+  }
 
-	public function cancel():Void
-	{
-		// This typo happens enough that I just added this.
-		cancelEvent();
-	}
+  /**
+   * Call this function on a cancelable event to cancel the associated behavior.
+   * For example, cancelling COUNTDOWN_START will prevent the countdown from starting.
+   */
+  public function cancelEvent():Void
+  {
+    if (cancelable)
+    {
+      eventCanceled = true;
+    }
+  }
 
-	/**
-	 * Call this function to stop any other Scripteds from receiving the event.
-	 */
-	public function stopPropagation():Void
-	{
-		shouldPropagate = false;
-	}
+  public function cancel():Void
+  {
+    // This typo happens enough that I just added this.
+    cancelEvent();
+  }
 
-	public function toString():String
-	{
-		return 'ScriptEvent(type=$type, cancelable=$cancelable)';
-	}
+  /**
+   * Call this function to stop any other Scripteds from receiving the event.
+   */
+  public function stopPropagation():Void
+  {
+    shouldPropagate = false;
+  }
+
+  public function toString():String
+  {
+    return 'ScriptEvent(type=$type, cancelable=$cancelable)';
+  }
 }
 
 /**
@@ -288,29 +296,29 @@ class ScriptEvent
  */
 class NoteScriptEvent extends ScriptEvent
 {
-	/**
-	 * The note associated with this event.
-	 * You cannot replace it, but you can edit it.
-	 */
-	public var note(default, null):Note;
+  /**
+   * The note associated with this event.
+   * You cannot replace it, but you can edit it.
+   */
+  public var note(default, null):Note;
 
-	/**
-	 * The combo count as it is with this event.
-	 * Will be (combo) on miss events and (combo + 1) on hit events (the stored combo count won't update if the event is cancelled).
-	 */
-	public var comboCount(default, null):Int;
+  /**
+   * The combo count as it is with this event.
+   * Will be (combo) on miss events and (combo + 1) on hit events (the stored combo count won't update if the event is cancelled).
+   */
+  public var comboCount(default, null):Int;
 
-	public function new(type:ScriptEventType, note:Note, comboCount:Int = 0, cancelable:Bool = false):Void
-	{
-		super(type, cancelable);
-		this.note = note;
-		this.comboCount = comboCount;
-	}
+  public function new(type:ScriptEventType, note:Note, comboCount:Int = 0, cancelable:Bool = false):Void
+  {
+    super(type, cancelable);
+    this.note = note;
+    this.comboCount = comboCount;
+  }
 
-	public override function toString():String
-	{
-		return 'NoteScriptEvent(type=' + type + ', cancelable=' + cancelable + ', note=' + note + ', comboCount=' + comboCount + ')';
-	}
+  public override function toString():String
+  {
+    return 'NoteScriptEvent(type=' + type + ', cancelable=' + cancelable + ', note=' + note + ', comboCount=' + comboCount + ')';
+  }
 }
 
 /**
@@ -318,52 +326,81 @@ class NoteScriptEvent extends ScriptEvent
  */
 class GhostMissNoteScriptEvent extends ScriptEvent
 {
-	/**
-	 * The direction that was mistakenly pressed.
-	 */
-	public var dir(default, null):NoteDir;
+  /**
+   * The direction that was mistakenly pressed.
+   */
+  public var dir(default, null):NoteDir;
 
-	/**
-	 * Whether there was a note within judgement range when this ghost note was pressed.
-	 */
-	public var hasPossibleNotes(default, null):Bool;
+  /**
+   * Whether there was a note within judgement range when this ghost note was pressed.
+   */
+  public var hasPossibleNotes(default, null):Bool;
 
-	/**
-	 * How much health should be lost when this ghost note is pressed.
-	 * Remember that max health is 2.00.
-	 */
-	public var healthChange(default, default):Float;
+  /**
+   * How much health should be lost when this ghost note is pressed.
+   * Remember that max health is 2.00.
+   */
+  public var healthChange(default, default):Float;
 
-	/**
-	 * How much score should be lost when this ghost note is pressed.
-	 */
-	public var scoreChange(default, default):Int;
+  /**
+   * How much score should be lost when this ghost note is pressed.
+   */
+  public var scoreChange(default, default):Int;
 
-	/**
-	 * Whether to play the record scratch sound.
-	 */
-	public var playSound(default, default):Bool;
+  /**
+   * Whether to play the record scratch sound.
+   */
+  public var playSound(default, default):Bool;
 
-	/**
-	 * Whether to play the miss animation on the player.
-	 */
-	public var playAnim(default, default):Bool;
+  /**
+   * Whether to play the miss animation on the player.
+   */
+  public var playAnim(default, default):Bool;
 
-	public function new(dir:NoteDir, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void
-	{
-		super(ScriptEvent.NOTE_GHOST_MISS, true);
-		this.dir = dir;
-		this.hasPossibleNotes = hasPossibleNotes;
-		this.healthChange = healthChange;
-		this.scoreChange = scoreChange;
-		this.playSound = true;
-		this.playAnim = true;
-	}
+  public function new(dir:NoteDir, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void
+  {
+    super(ScriptEvent.NOTE_GHOST_MISS, true);
+    this.dir = dir;
+    this.hasPossibleNotes = hasPossibleNotes;
+    this.healthChange = healthChange;
+    this.scoreChange = scoreChange;
+    this.playSound = true;
+    this.playAnim = true;
+  }
 
-	public override function toString():String
-	{
-		return 'GhostMissNoteScriptEvent(dir=' + dir + ', hasPossibleNotes=' + hasPossibleNotes + ')';
-	}
+  public override function toString():String
+  {
+    return 'GhostMissNoteScriptEvent(dir=' + dir + ', hasPossibleNotes=' + hasPossibleNotes + ')';
+  }
+}
+
+/**
+ * An event that is fired when the song reaches an event.
+ */
+class SongEventScriptEvent extends ScriptEvent
+{
+  /**
+   * The note associated with this event.
+   * You cannot replace it, but you can edit it.
+   */
+  public var event(default, null):funkin.play.song.SongData.SongEventData;
+
+  /**
+   * The combo count as it is with this event.
+   * Will be (combo) on miss events and (combo + 1) on hit events (the stored combo count won't update if the event is cancelled).
+   */
+  public var comboCount(default, null):Int;
+
+  public function new(event:funkin.play.song.SongData.SongEventData):Void
+  {
+    super(ScriptEvent.SONG_EVENT, true);
+    this.event = event;
+  }
+
+  public override function toString():String
+  {
+    return 'SongEventScriptEvent(event=' + event + ')';
+  }
 }
 
 /**
@@ -371,22 +408,22 @@ class GhostMissNoteScriptEvent extends ScriptEvent
  */
 class UpdateScriptEvent extends ScriptEvent
 {
-	/**
-	 * The note associated with this event.
-	 * You cannot replace it, but you can edit it.
-	 */
-	public var elapsed(default, null):Float;
+  /**
+   * The note associated with this event.
+   * You cannot replace it, but you can edit it.
+   */
+  public var elapsed(default, null):Float;
 
-	public function new(elapsed:Float):Void
-	{
-		super(ScriptEvent.UPDATE, false);
-		this.elapsed = elapsed;
-	}
+  public function new(elapsed:Float):Void
+  {
+    super(ScriptEvent.UPDATE, false);
+    this.elapsed = elapsed;
+  }
 
-	public override function toString():String
-	{
-		return 'UpdateScriptEvent(elapsed=$elapsed)';
-	}
+  public override function toString():String
+  {
+    return 'UpdateScriptEvent(elapsed=$elapsed)';
+  }
 }
 
 /**
@@ -395,27 +432,27 @@ class UpdateScriptEvent extends ScriptEvent
  */
 class SongTimeScriptEvent extends ScriptEvent
 {
-	/**
-	 * The current beat of the song.
-	 */
-	public var beat(default, null):Int;
+  /**
+   * The current beat of the song.
+   */
+  public var beat(default, null):Int;
 
-	/**
-	 * The current step of the song.
-	 */
-	public var step(default, null):Int;
+  /**
+   * The current step of the song.
+   */
+  public var step(default, null):Int;
 
-	public function new(type:ScriptEventType, beat:Int, step:Int):Void
-	{
-		super(type, true);
-		this.beat = beat;
-		this.step = step;
-	}
+  public function new(type:ScriptEventType, beat:Int, step:Int):Void
+  {
+    super(type, true);
+    this.beat = beat;
+    this.step = step;
+  }
 
-	public override function toString():String
-	{
-		return 'SongTimeScriptEvent(type=' + type + ', beat=' + beat + ', step=' + step + ')';
-	}
+  public override function toString():String
+  {
+    return 'SongTimeScriptEvent(type=' + type + ', beat=' + beat + ', step=' + step + ')';
+  }
 }
 
 /**
@@ -424,21 +461,21 @@ class SongTimeScriptEvent extends ScriptEvent
  */
 class CountdownScriptEvent extends ScriptEvent
 {
-	/**
-	 * The current step of the countdown.
-	 */
-	public var step(default, null):CountdownStep;
+  /**
+   * The current step of the countdown.
+   */
+  public var step(default, null):CountdownStep;
 
-	public function new(type:ScriptEventType, step:CountdownStep, cancelable = true):Void
-	{
-		super(type, cancelable);
-		this.step = step;
-	}
+  public function new(type:ScriptEventType, step:CountdownStep, cancelable = true):Void
+  {
+    super(type, cancelable);
+    this.step = step;
+  }
 
-	public override function toString():String
-	{
-		return 'CountdownScriptEvent(type=' + type + ', step=' + step + ')';
-	}
+  public override function toString():String
+  {
+    return 'CountdownScriptEvent(type=' + type + ', step=' + step + ')';
+  }
 }
 
 /**
@@ -446,21 +483,21 @@ class CountdownScriptEvent extends ScriptEvent
  */
 class KeyboardInputScriptEvent extends ScriptEvent
 {
-	/**
-	 * The associated keyboard event.
-	 */
-	public var event(default, null):KeyboardEvent;
+  /**
+   * The associated keyboard event.
+   */
+  public var event(default, null):KeyboardEvent;
 
-	public function new(type:ScriptEventType, event:KeyboardEvent):Void
-	{
-		super(type, false);
-		this.event = event;
-	}
+  public function new(type:ScriptEventType, event:KeyboardEvent):Void
+  {
+    super(type, false);
+    this.event = event;
+  }
 
-	public override function toString():String
-	{
-		return 'KeyboardInputScriptEvent(type=' + type + ', event=' + event + ')';
-	}
+  public override function toString():String
+  {
+    return 'KeyboardInputScriptEvent(type=' + type + ', event=' + event + ')';
+  }
 }
 
 /**
@@ -468,35 +505,35 @@ class KeyboardInputScriptEvent extends ScriptEvent
  */
 class SongLoadScriptEvent extends ScriptEvent
 {
-	/**
-	 * The note associated with this event.
-	 * You cannot replace it, but you can edit it.
-	 */
-	public var notes(default, set):Array<Note>;
+  /**
+   * The note associated with this event.
+   * You cannot replace it, but you can edit it.
+   */
+  public var notes(default, set):Array<Note>;
 
-	public var id(default, null):String;
+  public var id(default, null):String;
 
-	public var difficulty(default, null):String;
+  public var difficulty(default, null):String;
 
-	function set_notes(notes:Array<Note>):Array<Note>
-	{
-		this.notes = notes;
-		return this.notes;
-	}
+  function set_notes(notes:Array<Note>):Array<Note>
+  {
+    this.notes = notes;
+    return this.notes;
+  }
 
-	public function new(id:String, difficulty:String, notes:Array<Note>):Void
-	{
-		super(ScriptEvent.SONG_LOADED, false);
-		this.id = id;
-		this.difficulty = difficulty;
-		this.notes = notes;
-	}
+  public function new(id:String, difficulty:String, notes:Array<Note>):Void
+  {
+    super(ScriptEvent.SONG_LOADED, false);
+    this.id = id;
+    this.difficulty = difficulty;
+    this.notes = notes;
+  }
 
-	public override function toString():String
-	{
-		var noteStr = notes == null ? 'null' : 'Array(' + notes.length + ')';
-		return 'SongLoadScriptEvent(notes=$noteStr, id=$id, difficulty=$difficulty)';
-	}
+  public override function toString():String
+  {
+    var noteStr = notes == null ? 'null' : 'Array(' + notes.length + ')';
+    return 'SongLoadScriptEvent(notes=$noteStr, id=$id, difficulty=$difficulty)';
+  }
 }
 
 /**
@@ -504,21 +541,21 @@ class SongLoadScriptEvent extends ScriptEvent
  */
 class StateChangeScriptEvent extends ScriptEvent
 {
-	/**
-	 * The state the game is moving into.
-	 */
-	public var targetState(default, null):FlxState;
+  /**
+   * The state the game is moving into.
+   */
+  public var targetState(default, null):FlxState;
 
-	public function new(type:ScriptEventType, targetState:FlxState, cancelable:Bool = false):Void
-	{
-		super(type, cancelable);
-		this.targetState = targetState;
-	}
+  public function new(type:ScriptEventType, targetState:FlxState, cancelable:Bool = false):Void
+  {
+    super(type, cancelable);
+    this.targetState = targetState;
+  }
 
-	public override function toString():String
-	{
-		return 'StateChangeScriptEvent(type=' + type + ', targetState=' + targetState + ')';
-	}
+  public override function toString():String
+  {
+    return 'StateChangeScriptEvent(type=' + type + ', targetState=' + targetState + ')';
+  }
 }
 
 /**
@@ -526,21 +563,21 @@ class StateChangeScriptEvent extends ScriptEvent
  */
 class SubStateScriptEvent extends ScriptEvent
 {
-	/**
-	 * The state the game is moving into.
-	 */
-	public var targetState(default, null):FlxSubState;
+  /**
+   * The state the game is moving into.
+   */
+  public var targetState(default, null):FlxSubState;
 
-	public function new(type:ScriptEventType, targetState:FlxSubState, cancelable:Bool = false):Void
-	{
-		super(type, cancelable);
-		this.targetState = targetState;
-	}
+  public function new(type:ScriptEventType, targetState:FlxSubState, cancelable:Bool = false):Void
+  {
+    super(type, cancelable);
+    this.targetState = targetState;
+  }
 
-	public override function toString():String
-	{
-		return 'SubStateScriptEvent(type=' + type + ', targetState=' + targetState + ')';
-	}
+  public override function toString():String
+  {
+    return 'SubStateScriptEvent(type=' + type + ', targetState=' + targetState + ')';
+  }
 }
 
 /**
@@ -548,14 +585,14 @@ class SubStateScriptEvent extends ScriptEvent
  */
 class PauseScriptEvent extends ScriptEvent
 {
-	/**
-	 * Whether to use the Gitaroo Man pause.
-	 */
-	public var gitaroo(default, default):Bool;
+  /**
+   * Whether to use the Gitaroo Man pause.
+   */
+  public var gitaroo(default, default):Bool;
 
-	public function new(gitaroo:Bool):Void
-	{
-		super(ScriptEvent.PAUSE, true);
-		this.gitaroo = gitaroo;
-	}
+  public function new(gitaroo:Bool):Void
+  {
+    super(ScriptEvent.PAUSE, true);
+    this.gitaroo = gitaroo;
+  }
 }
diff --git a/source/funkin/modding/module/Module.hx b/source/funkin/modding/module/Module.hx
index d337ad719..0d76c2c14 100644
--- a/source/funkin/modding/module/Module.hx
+++ b/source/funkin/modding/module/Module.hx
@@ -10,109 +10,111 @@ import funkin.modding.events.ScriptEvent;
  */
 class Module implements IPlayStateScriptedClass implements IStateChangingScriptedClass
 {
-	/**
-	 * Whether the module is currently active.
-	 */
-	public var active(default, set):Bool = true;
+  /**
+   * Whether the module is currently active.
+   */
+  public var active(default, set):Bool = true;
 
-	function set_active(value:Bool):Bool
-	{
-		this.active = value;
-		return value;
-	}
+  function set_active(value:Bool):Bool
+  {
+    this.active = value;
+    return value;
+  }
 
-	public var moduleId(default, null):String = 'UNKNOWN';
+  public var moduleId(default, null):String = 'UNKNOWN';
 
-	/**
-	 * Determines the order in which modules receive events.
-	 * You can modify this to change the order in which a given module receives events.
-	 * 
-	 * Priority 1 is processed before Priority 1000, etc.
-	 */
-	public var priority(default, set):Int;
+  /**
+   * Determines the order in which modules receive events.
+   * You can modify this to change the order in which a given module receives events.
+   * 
+   * Priority 1 is processed before Priority 1000, etc.
+   */
+  public var priority(default, set):Int;
 
-	function set_priority(value:Int):Int
-	{
-		this.priority = value;
-		@:privateAccess
-		ModuleHandler.reorderModuleCache();
-		return value;
-	}
+  function set_priority(value:Int):Int
+  {
+    this.priority = value;
+    @:privateAccess
+    ModuleHandler.reorderModuleCache();
+    return value;
+  }
 
-	/**
-	 * Called when the module is initialized.
-	 * It may not be safe to reference other modules here since they may not be loaded yet.
-	 * 
-	 * NOTE: To make the module start inactive, call `this.active = false` in the constructor.
-	 */
-	public function new(moduleId:String, priority:Int = 1000):Void
-	{
-		this.moduleId = moduleId;
-		this.priority = priority;
-	}
+  /**
+   * Called when the module is initialized.
+   * It may not be safe to reference other modules here since they may not be loaded yet.
+   * 
+   * NOTE: To make the module start inactive, call `this.active = false` in the constructor.
+   */
+  public function new(moduleId:String, priority:Int = 1000):Void
+  {
+    this.moduleId = moduleId;
+    this.priority = priority;
+  }
 
-	public function toString()
-	{
-		return 'Module(' + this.moduleId + ')';
-	}
+  public function toString()
+  {
+    return 'Module(' + this.moduleId + ')';
+  }
 
-	// TODO: Half of these aren't actually being called!!!!!!!
+  // TODO: Half of these aren't actually being called!!!!!!!
 
-	public function onScriptEvent(event:ScriptEvent) {}
+  public function onScriptEvent(event:ScriptEvent) {}
 
-	/**
-	 * Called when the module is first created.
-	 * This happens before the title screen appears!
-	 */
-	public function onCreate(event:ScriptEvent) {}
+  /**
+   * Called when the module is first created.
+   * This happens before the title screen appears!
+   */
+  public function onCreate(event:ScriptEvent) {}
 
-	/**
-	 * Called when a module is destroyed.
-	 * This currently only happens when reloading modules with F5.
-	 */
-	public function onDestroy(event:ScriptEvent) {}
+  /**
+   * Called when a module is destroyed.
+   * This currently only happens when reloading modules with F5.
+   */
+  public function onDestroy(event:ScriptEvent) {}
 
-	public function onUpdate(event:UpdateScriptEvent) {}
+  public function onUpdate(event:UpdateScriptEvent) {}
 
-	public function onPause(event:PauseScriptEvent) {}
+  public function onPause(event:PauseScriptEvent) {}
 
-	public function onResume(event:ScriptEvent) {}
+  public function onResume(event:ScriptEvent) {}
 
-	public function onSongStart(event:ScriptEvent) {}
+  public function onSongStart(event:ScriptEvent) {}
 
-	public function onSongEnd(event:ScriptEvent) {}
+  public function onSongEnd(event:ScriptEvent) {}
 
-	public function onGameOver(event:ScriptEvent) {}
+  public function onGameOver(event:ScriptEvent) {}
 
-	public function onNoteHit(event:NoteScriptEvent) {}
+  public function onNoteHit(event:NoteScriptEvent) {}
 
-	public function onNoteMiss(event:NoteScriptEvent) {}
+  public function onNoteMiss(event:NoteScriptEvent) {}
 
-	public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {}
+  public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {}
 
-	public function onStepHit(event:SongTimeScriptEvent) {}
+  public function onStepHit(event:SongTimeScriptEvent) {}
 
-	public function onBeatHit(event:SongTimeScriptEvent) {}
+  public function onBeatHit(event:SongTimeScriptEvent) {}
 
-	public function onCountdownStart(event:CountdownScriptEvent) {}
+  public function onSongEvent(event:SongEventScriptEvent) {}
 
-	public function onCountdownStep(event:CountdownScriptEvent) {}
+  public function onCountdownStart(event:CountdownScriptEvent) {}
 
-	public function onCountdownEnd(event:CountdownScriptEvent) {}
+  public function onCountdownStep(event:CountdownScriptEvent) {}
 
-	public function onSongLoaded(event:SongLoadScriptEvent) {}
+  public function onCountdownEnd(event:CountdownScriptEvent) {}
 
-	public function onStateChangeBegin(event:StateChangeScriptEvent) {}
+  public function onSongLoaded(event:SongLoadScriptEvent) {}
 
-	public function onStateChangeEnd(event:StateChangeScriptEvent) {}
+  public function onStateChangeBegin(event:StateChangeScriptEvent) {}
 
-	public function onSubstateOpenBegin(event:SubStateScriptEvent) {}
+  public function onStateChangeEnd(event:StateChangeScriptEvent) {}
 
-	public function onSubstateOpenEnd(event:SubStateScriptEvent) {}
+  public function onSubstateOpenBegin(event:SubStateScriptEvent) {}
 
-	public function onSubstateCloseBegin(event:SubStateScriptEvent) {}
+  public function onSubstateOpenEnd(event:SubStateScriptEvent) {}
 
-	public function onSubstateCloseEnd(event:SubStateScriptEvent) {}
+  public function onSubstateCloseBegin(event:SubStateScriptEvent) {}
 
-	public function onSongRetry(event:ScriptEvent) {}
+  public function onSubstateCloseEnd(event:SubStateScriptEvent) {}
+
+  public function onSongRetry(event:ScriptEvent) {}
 }
diff --git a/source/funkin/modding/module/ModuleHandler.hx b/source/funkin/modding/module/ModuleHandler.hx
index 93ac7bc66..51d723d0d 100644
--- a/source/funkin/modding/module/ModuleHandler.hx
+++ b/source/funkin/modding/module/ModuleHandler.hx
@@ -11,132 +11,137 @@ import funkin.modding.module.ScriptedModule;
  */
 class ModuleHandler
 {
-	static final moduleCache:Map<String, Module> = new Map<String, Module>();
-	static var modulePriorityOrder:Array<String> = [];
+  static final moduleCache:Map<String, Module> = new Map<String, Module>();
+  static var modulePriorityOrder:Array<String> = [];
 
-	/**
-	 * Parses and preloads the game's stage data and scripts when the game starts.
-	 * 
-	 * If you want to force stages to be reloaded, you can just call this function again.
-	 */
-	public static function loadModuleCache():Void
-	{
-		// Clear any stages that are cached if there were any.
-		clearModuleCache();
-		trace("[MODULEHANDLER] Loading module cache...");
+  /**
+   * Parses and preloads the game's stage data and scripts when the game starts.
+   * 
+   * If you want to force stages to be reloaded, you can just call this function again.
+   */
+  public static function loadModuleCache():Void
+  {
+    // Clear any stages that are cached if there were any.
+    clearModuleCache();
+    trace("[MODULEHANDLER] Loading module cache...");
 
-		var scriptedModuleClassNames:Array<String> = ScriptedModule.listScriptClasses();
-		trace('  Instantiating ${scriptedModuleClassNames.length} modules...');
-		for (moduleCls in scriptedModuleClassNames)
-		{
-			var module:Module = ScriptedModule.init(moduleCls, moduleCls);
-			if (module != null)
-			{
-				trace('    Loaded module: ${moduleCls}');
+    var scriptedModuleClassNames:Array<String> = ScriptedModule.listScriptClasses();
+    trace('  Instantiating ${scriptedModuleClassNames.length} modules...');
+    for (moduleCls in scriptedModuleClassNames)
+    {
+      var module:Module = ScriptedModule.init(moduleCls, moduleCls);
+      if (module != null)
+      {
+        trace('    Loaded module: ${moduleCls}');
 
-				// Then store it.
-				addToModuleCache(module);
-			}
-			else
-			{
-				trace('    Failed to instantiate module: ${moduleCls}');
-			}
-		}
-		reorderModuleCache();
+        // Then store it.
+        addToModuleCache(module);
+      }
+      else
+      {
+        trace('    Failed to instantiate module: ${moduleCls}');
+      }
+    }
+    reorderModuleCache();
 
-		trace("[MODULEHANDLER] Module cache loaded.");
-	}
+    trace("[MODULEHANDLER] Module cache loaded.");
+  }
 
-	public static function buildModuleCallbacks():Void
-	{
-		FlxG.signals.postStateSwitch.add(onStateSwitchComplete);
-	}
+  public static function buildModuleCallbacks():Void
+  {
+    FlxG.signals.postStateSwitch.add(onStateSwitchComplete);
+  }
 
-	static function onStateSwitchComplete():Void
-	{
-		callEvent(new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_END, FlxG.state, true));
-	}
+  static function onStateSwitchComplete():Void
+  {
+    callEvent(new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_END, FlxG.state, true));
+  }
 
-	static function addToModuleCache(module:Module):Void
-	{
-		moduleCache.set(module.moduleId, module);
-	}
+  static function addToModuleCache(module:Module):Void
+  {
+    moduleCache.set(module.moduleId, module);
+  }
 
-	static function reorderModuleCache():Void
-	{
-		modulePriorityOrder = moduleCache.keys().array();
+  static function reorderModuleCache():Void
+  {
+    modulePriorityOrder = moduleCache.keys().array();
 
-		modulePriorityOrder.sort(function(a:String, b:String):Int
-		{
-			var aModule:Module = moduleCache.get(a);
-			var bModule:Module = moduleCache.get(b);
+    modulePriorityOrder.sort(function(a:String, b:String):Int
+    {
+      var aModule:Module = moduleCache.get(a);
+      var bModule:Module = moduleCache.get(b);
 
-			if (aModule.priority != bModule.priority)
-			{
-				return aModule.priority - bModule.priority;
-			}
-			else
-			{
-				// Sort alphabetically. Yes that's how this works.
-				return a > b ? 1 : -1;
-			}
-		});
-	}
+      if (aModule.priority != bModule.priority)
+      {
+        return aModule.priority - bModule.priority;
+      }
+      else
+      {
+        // Sort alphabetically. Yes that's how this works.
+        return a > b ? 1 : -1;
+      }
+    });
+  }
 
-	public static function getModule(moduleId:String):Module
-	{
-		return moduleCache.get(moduleId);
-	}
+  public static function getModule(moduleId:String):Module
+  {
+    return moduleCache.get(moduleId);
+  }
 
-	public static function activateModule(moduleId:String):Void
-	{
-		var module:Module = getModule(moduleId);
-		if (module != null)
-		{
-			module.active = true;
-		}
-	}
+  public static function activateModule(moduleId:String):Void
+  {
+    var module:Module = getModule(moduleId);
+    if (module != null)
+    {
+      module.active = true;
+    }
+  }
 
-	public static function deactivateModule(moduleId:String):Void
-	{
-		var module:Module = getModule(moduleId);
-		if (module != null)
-		{
-			module.active = false;
-		}
-	}
+  public static function deactivateModule(moduleId:String):Void
+  {
+    var module:Module = getModule(moduleId);
+    if (module != null)
+    {
+      module.active = false;
+    }
+  }
 
-	/**
-	 * Clear the module cache, forcing all modules to call shutdown events.
-	 */
-	public static function clearModuleCache():Void
-	{
-		if (moduleCache != null)
-		{
-			var event = new ScriptEvent(ScriptEvent.DESTROY, false);
+  /**
+   * Clear the module cache, forcing all modules to call shutdown events.
+   */
+  public static function clearModuleCache():Void
+  {
+    if (moduleCache != null)
+    {
+      var event = new ScriptEvent(ScriptEvent.DESTROY, false);
 
-			// Note: Ignore stopPropagation()
-			for (key => value in moduleCache)
-			{
-				ScriptEventDispatcher.callEvent(value, event);
-				moduleCache.remove(key);
-			}
+      // Note: Ignore stopPropagation()
+      for (key => value in moduleCache)
+      {
+        ScriptEventDispatcher.callEvent(value, event);
+        moduleCache.remove(key);
+      }
 
-			moduleCache.clear();
-			modulePriorityOrder = [];
-		}
-	}
+      moduleCache.clear();
+      modulePriorityOrder = [];
+    }
+  }
 
-	public static function callEvent(event:ScriptEvent):Void
-	{
-		for (moduleId in modulePriorityOrder)
-		{
-			var module:Module = moduleCache.get(moduleId);
-			// The module needs to be active to receive events.
-			if (module != null && module.active)
-			{
-				ScriptEventDispatcher.callEvent(module, event);
-			}
-		}
-	}
+  public static function callEvent(event:ScriptEvent):Void
+  {
+    for (moduleId in modulePriorityOrder)
+    {
+      var module:Module = moduleCache.get(moduleId);
+      // The module needs to be active to receive events.
+      if (module != null && module.active)
+      {
+        ScriptEventDispatcher.callEvent(module, event);
+      }
+    }
+  }
+
+  public static inline function callOnCreate():Void
+  {
+    callEvent(new ScriptEvent(ScriptEvent.CREATE, false));
+  }
 }
diff --git a/source/funkin/noteStuff/NoteUtil.hx b/source/funkin/noteStuff/NoteUtil.hx
index 054ec2fef..c4d552b8b 100644
--- a/source/funkin/noteStuff/NoteUtil.hx
+++ b/source/funkin/noteStuff/NoteUtil.hx
@@ -13,87 +13,87 @@ import openfl.Assets;
  */
 class NoteUtil
 {
-	/**
-	 * IDK THING FOR BOTH LOL! DIS SHIT HACK-Y
-	 * @param jsonPath 
-	 * @return Map<Int, Array<SongEventInfo>>
-	 */
-	public static function loadSongEvents(jsonPath:String):Map<Int, Array<SongEventInfo>>
-	{
-		return parseSongEvents(loadSongEventFromJson(jsonPath));
-	}
+  /**
+   * IDK THING FOR BOTH LOL! DIS SHIT HACK-Y
+   * @param jsonPath 
+   * @return Map<Int, Array<SongEventInfo>>
+   */
+  public static function loadSongEvents(jsonPath:String):Map<Int, Array<SongEventInfo>>
+  {
+    return parseSongEvents(loadSongEventFromJson(jsonPath));
+  }
 
-	public static function loadSongEventFromJson(jsonPath:String):Array<SongEvent>
-	{
-		var daEvents:Array<SongEvent>;
-		daEvents = cast Json.parse(Assets.getText(jsonPath)).events; // DUMB LIL DETAIL HERE: MAKE SURE THAT .events IS THERE??
-		trace('GET JSON SONG EVENTS:');
-		trace(daEvents);
-		return daEvents;
-	}
+  public static function loadSongEventFromJson(jsonPath:String):Array<SongEvent>
+  {
+    var daEvents:Array<SongEvent>;
+    daEvents = cast Json.parse(Assets.getText(jsonPath)).events; // DUMB LIL DETAIL HERE: MAKE SURE THAT .events IS THERE??
+    trace('GET JSON SONG EVENTS:');
+    trace(daEvents);
+    return daEvents;
+  }
 
-	/**
-	 * Parses song event json stuff into a neater lil map grouping?
-	 * @param songEvents 
-	 */
-	public static function parseSongEvents(songEvents:Array<SongEvent>):Map<Int, Array<SongEventInfo>>
-	{
-		var songData:Map<Int, Array<SongEventInfo>> = new Map();
+  /**
+   * Parses song event json stuff into a neater lil map grouping?
+   * @param songEvents 
+   */
+  public static function parseSongEvents(songEvents:Array<SongEvent>):Map<Int, Array<SongEventInfo>>
+  {
+    var songData:Map<Int, Array<SongEventInfo>> = new Map();
 
-		for (songEvent in songEvents)
-		{
-			trace(songEvent);
-			if (songData[songEvent.t] == null)
-				songData[songEvent.t] = [];
+    for (songEvent in songEvents)
+    {
+      trace(songEvent);
+      if (songData[songEvent.t] == null)
+        songData[songEvent.t] = [];
 
-			songData[songEvent.t].push({songEventType: songEvent.e, value: songEvent.v, activated: false});
-		}
+      songData[songEvent.t].push({songEventType: songEvent.e, value: songEvent.v, activated: false});
+    }
 
-		trace("FINISH SONG EVENTS!");
-		trace(songData);
+    trace("FINISH SONG EVENTS!");
+    trace(songData);
 
-		return songData;
-	}
+    return songData;
+  }
 
-	public static function checkSongEvents(songData:Map<Int, Array<SongEventInfo>>, time:Float)
-	{
-		for (eventGrp in songData.keys())
-		{
-			if (time >= eventGrp)
-			{
-				for (events in songData[eventGrp])
-				{
-					if (!events.activated)
-					{
-						// TURN TO NICER SWITCH STATEMENT CHECKER OF EVENT TYPES!!
-						trace(events.value);
-						trace(eventGrp);
-						trace(Conductor.songPosition);
-						events.activated = true;
-					}
-				}
-			}
-		}
-	}
+  public static function checkSongEvents(songData:Map<Int, Array<SongEventInfo>>, time:Float)
+  {
+    for (eventGrp in songData.keys())
+    {
+      if (time >= eventGrp)
+      {
+        for (events in songData[eventGrp])
+        {
+          if (!events.activated)
+          {
+            // TURN TO NICER SWITCH STATEMENT CHECKER OF EVENT TYPES!!
+            trace(events.value);
+            trace(eventGrp);
+            trace(Conductor.songPosition);
+            events.activated = true;
+          }
+        }
+      }
+    }
+  }
 }
 
 typedef SongEventInfo =
 {
-	var songEventType:SongEventType;
-	var value:Dynamic;
-	var activated:Bool;
+  var songEventType:SongEventType;
+  var value:Dynamic;
+  var activated:Bool;
 }
 
 typedef SongEvent =
 {
-	var t:Int;
-	var e:SongEventType;
-	var v:Dynamic;
+  var t:Int;
+  var e:SongEventType;
+  var v:Dynamic;
 }
 
 enum abstract SongEventType(String)
 {
-	var FocusCamera;
-	var PlayCharAnim;
-	var Trace;
+  var FocusCamera;
+  var PlayCharAnim;
+  var Trace;
 }
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index f5fff3cf5..5c166d43d 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1,5 +1,7 @@
 package funkin.play;
 
+import funkin.play.song.SongData.SongEventData;
+import funkin.play.event.SongEvent.SongEventParser;
 import flixel.FlxCamera;
 import flixel.FlxObject;
 import flixel.FlxSprite;
@@ -29,7 +31,6 @@ import funkin.play.Strumline.StrumlineArrow;
 import funkin.play.Strumline.StrumlineStyle;
 import funkin.play.character.BaseCharacter;
 import funkin.play.character.CharacterData;
-import funkin.play.event.SongEvent;
 import funkin.play.scoring.Scoring;
 import funkin.play.song.Song;
 import funkin.play.song.SongData.SongNoteData;
@@ -49,2612 +50,2608 @@ import Discord.DiscordClient;
 
 class PlayState extends MusicBeatState
 {
-	/**
-	 * STATIC VARIABLES
-	 * Static variables should be used for information that must be persisted between states or between resets,
-	 * such as the active song or song playlist.
-	 */
-	/**
-	 * The currently active PlayState.
-	 * Since there is only one PlayState in existance at a time, we can use a singleton.
-	 */
-	public static var instance:PlayState = null;
-
-	/**
-	 * The currently active song. Includes data about what stage should be used, what characters,
-	 * and the notes to be played.
-	 */
-	public static var currentSong:SwagSong = null;
-
-	public static var currentSong_NEW:Song = null;
-
-	/**
-	 * Whether the game is currently in Story Mode. If false, we are in Free Play Mode.
-	 */
-	public static var isStoryMode:Bool = false;
-
-	/**
-	 * Whether the game is currently in Practice Mode.
-	 * If true, player will not lose gain or lose score from notes.
-	 */
-	public static var isPracticeMode:Bool = false;
-
-	/**
-	 * Whether the game is currently in a cutscene, and gameplay should be stopped.
-	 */
-	public static var isInCutscene:Bool = false;
-
-	/**
-	 * Whether the game is currently in the countdown before the song resumes.
-	 */
-	public static var isInCountdown:Bool = false;
-
-	/**
-	 * Gets set to true when the PlayState needs to reset (player opted to restart or died).
-	 * Gets disabled once resetting happens.
-	 */
-	public static var needsReset:Bool = false;
-
-	/**
-	 * The current "Blueball Counter" to display in the pause menu.
-	 * Resets when you beat a song or go back to the main menu.
-	 */
-	public static var deathCounter:Int = 0;
-
-	/**
-	 * The default camera zoom level. The camera lerps back to this after zooming in.
-	 * Defaults to 1.05 but may be larger or smaller depending on the current stage.
-	 */
-	public static var defaultCameraZoom:Float = 1.05;
-
-	/**
-	 * Used to persist the position of the `cameraFollowPosition` between resets.
-	 */
-	private static var previousCameraFollowPoint:FlxObject = null;
-
-	/**
-	 * PUBLIC INSTANCE VARIABLES
-	 * Public instance variables should be used for information that must be reset or dereferenced
-	 * every time the state is reset, such as the currently active stage, but may need to be accessed externally.
-	 */
-	/**
-	 * The currently active Stage. This is the object containing all the props.
-	 */
-	public var currentStage:Stage = null;
-
-	public var currentChart(get, null):SongDifficulty;
-
-	/**
-	 * The internal ID of the currently active Stage.
-	 * Used to retrieve the data required to build the `currentStage`.
-	 */
-	public var currentStageId:String = '';
-
-	/**
-	 * The player's current health.
-	 * The default maximum health is 2.0, and the default starting health is 1.0.
-	 */
-	public var health:Float = 1;
-
-	/**
-	 * The player's current score.
-	 */
-	public var songScore:Int = 0;
-
-	/**
-	 * An empty FlxObject contained in the scene.
-	 * The current gameplay camera will be centered on this object. Tween its position to move the camera smoothly.
-	 * 
-	 * This is an FlxSprite for two reasons:
-	 * 1. It needs to be an object in the scene for the camera to be configured to follow it.
-	 * 2. It needs to be an FlxSprite to allow a graphic (optionally, for debug purposes) to be drawn on it.
-	 */
-	public var cameraFollowPoint:FlxSprite = new FlxSprite(0, 0);
-
-	/**
-	 * PRIVATE INSTANCE VARIABLES
-	 * Private instance variables should be used for information that must be reset or dereferenced
-	 * every time the state is reset, but should not be accessed externally.
-	 */
-	/**
-	 * The Array containing the notes that are not currently on the screen.
-	 * The `update()` function regularly shifts these out to add new notes to the screen.
-	 */
-	private var inactiveNotes:Array<Note>;
-
-	private var songEvents:Array<SongEvent>;
-
-	/**
-	 * If true, the player is allowed to pause the game.
-	 * Disabled during the ending of a song.
-	 */
-	private var mayPauseGame:Bool = true;
-
-	/**
-	 * The displayed value of the player's health.
-	 * Used to provide smooth animations based on linear interpolation of the player's health.
-	 */
-	private var healthLerp:Float = 1;
-
-	/**
-	 * Forcibly disables all update logic while the game moves back to the Menu state.
-	 * This is used only when a critical error occurs and the game cannot continue.
-	 */
-	private var criticalFailure:Bool = false;
-
-	/**
-	 * RENDER OBJECTS
-	 */
-	/**
-	 * The SpriteGroup containing the notes that are currently on the screen or are about to be on the screen.
-	 */
-	private var activeNotes:FlxTypedGroup<Note> = null;
-
-	/**
-	 * The FlxText which displays the current score.
-	 */
-	private var scoreText:FlxText;
-
-	/**
-	 * The bar which displays the player's health.
-	 * Dynamically updated based on the value of `healthLerp` (which is based on `health`).
-	 */
-	public var healthBar:FlxBar;
-
-	/**
-	 * The background image used for the health bar.
-	 * Emma says the image is slightly skewed so I'm leaving it as an image instead of a `createGraphic`.
-	 */
-	public var healthBarBG:FlxSprite;
-
-	/**
-	 * The health icon representing the player.
-	 */
-	public var iconP1:HealthIcon;
-
-	/**
-	 * The health icon representing the opponent.
-	 */
-	public var iconP2:HealthIcon;
-
-	/**
-	 * The sprite group containing active player's strumline notes.
-	 */
-	public var playerStrumline:Strumline;
-
-	/**
-	 * The sprite group containing opponent's strumline notes.
-	 */
-	public var enemyStrumline:Strumline;
-
-	/**
-	 * The camera which contains, and controls visibility of, the user interface elements.
-	 */
-	public var camHUD:FlxCamera;
-
-	/**
-	 * The camera which contains, and controls visibility of, the stage and characters.
-	 */
-	public var camGame:FlxCamera;
-
-	/**
-	 * PROPERTIES
-	 */
-	/**
-	 * If a substate is rendering over the PlayState, it is paused and normal update logic is skipped.
-	 * Examples include:
-	 * - The Pause screen is open.
-	 * - The Game Over screen is open.
-	 * - The Chart Editor screen is open.
-	 */
-	private var isGamePaused(get, never):Bool;
-
-	function get_isGamePaused():Bool
-	{
-		// Note: If there is a substate which requires the game to act unpaused,
-		//       this should be changed to include something like `&& Std.isOfType()`
-		return this.subState != null;
-	}
-
-	// TODO: Reorganize these variables (maybe there should be a separate class like Conductor just to hold them?)
-	public static var storyWeek:Int = 0;
-	public static var storyPlaylist:Array<String> = [];
-	public static var storyDifficulty:Int = 1;
-	public static var storyDifficulty_NEW:String = "normal";
-	public static var seenCutscene:Bool = false;
-	public static var campaignScore:Int = 0;
-
-	private var vocals:VoicesGroup;
-	private var vocalsFinished:Bool = false;
-
-	private var camZooming:Bool = false;
-	private var gfSpeed:Int = 1;
-	// private var combo:Int = 0;
-	private var generatedMusic:Bool = false;
-	private var startingSong:Bool = false;
-
-	var dialogue:Array<String>;
-	var talking:Bool = true;
-	var doof:DialogueBox;
-	var grpNoteSplashes:FlxTypedGroup<NoteSplash>;
-	var comboPopUps:PopUpStuff;
-	var perfectMode:Bool = false;
-	var previousFrameTime:Int = 0;
-	var songTime:Float = 0;
-
-	#if discord_rpc
-	// Discord RPC variables
-	var storyDifficultyText:String = "";
-	var iconRPC:String = "";
-	var songLength:Float = 0;
-	var detailsText:String = "";
-	var detailsPausedText:String = "";
-	#end
-
-	override public function create()
-	{
-		super.create();
-
-		if (currentSong == null && currentSong_NEW == null)
-		{
-			criticalFailure = true;
-
-			lime.app.Application.current.window.alert("There was a critical error while accessing the selected song. Click OK to return to the main menu.",
-				"Error loading PlayState");
-			FlxG.switchState(new MainMenuState());
-			return;
-		}
-
-		instance = this;
-
-		if (currentSong_NEW != null)
-		{
-			// TODO: Do this in the loading state.
-			currentSong_NEW.cacheCharts(true);
-		}
-
-		// Displays the camera follow point as a sprite for debug purposes.
-		// TODO: Put this on a toggle?
-		cameraFollowPoint.makeGraphic(8, 8, 0xFF00FF00);
-		cameraFollowPoint.visible = false;
-		cameraFollowPoint.zIndex = 1000000;
-
-		// Reduce physics accuracy (who cares!!!) to improve animation quality.
-		FlxG.fixedTimestep = false;
-
-		// This state receives update() even when a substate is active.
-		this.persistentUpdate = true;
-		// This state receives draw calls even when a substate is active.
-		this.persistentDraw = true;
-
-		// Stop any pre-existing music.
-		if (FlxG.sound.music != null)
-			FlxG.sound.music.stop();
-
-		// Prepare the current song to be played.
-		if (currentChart != null)
-		{
-			currentChart.cacheInst();
-			currentChart.cacheVocals();
-		}
-		else
-		{
-			FlxG.sound.cache(Paths.inst(currentSong.song));
-			FlxG.sound.cache(Paths.voices(currentSong.song));
-		}
-
-		// Initialize stage stuff.
-		initCameras();
-
-		if (currentSong == null && currentSong_NEW == null)
-		{
-			currentSong = SongLoad.loadFromJson('tutorial');
-		}
-
-		if (currentSong_NEW != null)
-		{
-			Conductor.mapTimeChanges(currentChart.timeChanges);
-			// Conductor.bpm = currentChart.getStartingBPM();
-
-			// TODO: Support for dialog.
-		}
-		else
-		{
-			Conductor.mapBPMChanges(currentSong);
-			// Conductor.bpm = currentSong.bpm;
-
-			switch (currentSong.song.toLowerCase())
-			{
-				case 'senpai':
-					dialogue = CoolUtil.coolTextFile(Paths.txt('songs/senpai/senpaiDialogue'));
-				case 'roses':
-					dialogue = CoolUtil.coolTextFile(Paths.txt('songs/roses/rosesDialogue'));
-				case 'thorns':
-					dialogue = CoolUtil.coolTextFile(Paths.txt('songs/thorns/thornsDialogue'));
-			}
-		}
-
-		Conductor.update(-5000);
-
-		if (dialogue != null)
-		{
-			doof = new DialogueBox(false, dialogue);
-			doof.scrollFactor.set();
-			doof.finishThing = startCountdown;
-			doof.cameras = [camHUD];
-		}
-
-		// Once the song is loaded, we can continue and initialize the stage.
-
-		var healthBarYPos:Float = PreferencesMenu.getPref('downscroll') ? FlxG.height * 0.1 : FlxG.height * 0.9;
-		healthBarBG = new FlxSprite(0, healthBarYPos).loadGraphic(Paths.image('healthBar'));
-		healthBarBG.screenCenter(X);
-		healthBarBG.scrollFactor.set(0, 0);
-		add(healthBarBG);
-
-		healthBar = new FlxBar(healthBarBG.x + 4, healthBarBG.y + 4, RIGHT_TO_LEFT, Std.int(healthBarBG.width - 8), Std.int(healthBarBG.height - 8), this,
-			'healthLerp', 0, 2);
-		healthBar.scrollFactor.set();
-		healthBar.createFilledBar(Constants.COLOR_HEALTH_BAR_RED, Constants.COLOR_HEALTH_BAR_GREEN);
-		add(healthBar);
-
-		initStage();
-		initCharacters();
-		#if discord_rpc
-		initDiscord();
-		#end
-
-		// Configure camera follow point.
-		if (previousCameraFollowPoint != null)
-		{
-			cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y);
-			previousCameraFollowPoint = null;
-		}
-		add(cameraFollowPoint);
-
-		comboPopUps = new PopUpStuff();
-		comboPopUps.cameras = [camHUD];
-		add(comboPopUps);
-
-		grpNoteSplashes = new FlxTypedGroup<NoteSplash>();
-
-		var noteSplash:NoteSplash = new NoteSplash(100, 100, 0);
-		grpNoteSplashes.add(noteSplash);
-		noteSplash.alpha = 0.1;
-
-		add(grpNoteSplashes);
-
-		if (currentSong_NEW != null)
-		{
-			generateSong_NEW();
-		}
-		else
-		{
-			generateSong();
-		}
-
-		resetCamera();
-
-		FlxG.worldBounds.set(0, 0, FlxG.width, FlxG.height);
-
-		scoreText = new FlxText(healthBarBG.x + healthBarBG.width - 190, healthBarBG.y + 30, 0, "", 20);
-		scoreText.setFormat(Paths.font("vcr.ttf"), 16, FlxColor.WHITE, RIGHT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK);
-		scoreText.scrollFactor.set();
-		add(scoreText);
-
-		// Attach the groups to the HUD camera so they are rendered independent of the stage.
-		grpNoteSplashes.cameras = [camHUD];
-		activeNotes.cameras = [camHUD];
-		healthBar.cameras = [camHUD];
-		healthBarBG.cameras = [camHUD];
-		iconP1.cameras = [camHUD];
-		iconP2.cameras = [camHUD];
-		scoreText.cameras = [camHUD];
-		leftWatermarkText.cameras = [camHUD];
-		rightWatermarkText.cameras = [camHUD];
-
-		// if (SONG.song == 'South')
-		// FlxG.camera.alpha = 0.7;
-		// UI_camera.zoom = 1;
-
-		// cameras = [FlxG.cameras.list[1]];
-		startingSong = true;
-
-		if (isStoryMode && !seenCutscene)
-		{
-			seenCutscene = true;
-
-			switch (currentSong.song.toLowerCase())
-			{
-				case "winter-horrorland":
-					VanillaCutscenes.playHorrorStartCutscene();
-				case 'senpai' | 'roses' | 'thorns':
-					schoolIntro(doof); // doof is assumed to be non-null, lol!
-				case 'ugh':
-					VanillaCutscenes.playUghCutscene();
-				case 'stress':
-					VanillaCutscenes.playStressCutscene();
-				case 'guns':
-					VanillaCutscenes.playGunsCutscene();
-				default:
-					// VanillaCutscenes will call startCountdown later.
-					// TODO: Alternatively: make a song script that allows startCountdown to be called,
-					// then cancels the countdown, hides the strumline, plays the cutscene,
-					// then calls Countdown.performCountdown()
-					startCountdown();
-			}
-		}
-		else
-		{
-			startCountdown();
-		}
-
-		#if debug
-		this.rightWatermarkText.text = Constants.VERSION;
-		#end
-	}
-
-	function get_currentChart():SongDifficulty
-	{
-		if (currentSong_NEW == null || storyDifficulty_NEW == null)
-			return null;
-		return currentSong_NEW.getDifficulty(storyDifficulty_NEW);
-	}
-
-	/**
-	 * Initializes the game and HUD cameras.
-	 */
-	function initCameras()
-	{
-		// Configure the default camera zoom level.
-		defaultCameraZoom = FlxCamera.defaultZoom * 1.05;
-
-		camGame = new SwagCamera();
-		camHUD = new FlxCamera();
-		camHUD.bgColor.alpha = 0;
-
-		FlxG.cameras.reset(camGame);
-		FlxG.cameras.add(camHUD, false);
-	}
-
-	function initStage()
-	{
-		if (currentSong_NEW != null)
-		{
-			initStage_NEW();
-			return;
-		}
-
-		// TODO: Move stageId to the song file.
-		switch (currentSong.song.toLowerCase())
-		{
-			case 'spookeez' | 'monster' | 'south':
-				currentStageId = "spookyMansion";
-			case 'pico' | 'blammed' | 'philly':
-				currentStageId = 'phillyTrain';
-			case "milf" | 'satin-panties' | 'high':
-				currentStageId = 'limoRide';
-			case "cocoa" | 'eggnog':
-				currentStageId = 'mallXmas';
-			case 'winter-horrorland':
-				currentStageId = 'mallEvil';
-			case 'senpai' | 'roses':
-				currentStageId = 'school';
-			case "darnell" | "lit-up" | "2hot":
-				currentStageId = 'phillyStreets';
-			// currentStageId = 'pyro';
-			case "blazin":
-				currentStageId = 'phillyBlazin';
-			// currentStageId = 'pyro';
-			case 'pyro':
-				currentStageId = 'pyro';
-			case 'thorns':
-				currentStageId = 'schoolEvil';
-			case 'guns' | 'stress' | 'ugh':
-				currentStageId = 'tankmanBattlefield';
-			default:
-				currentStageId = "mainStage";
-		}
-		// Loads the relevant stage based on its ID.
-		loadStage(currentStageId);
-	}
-
-	function initStage_NEW()
-	{
-		if (currentChart == null)
-		{
-			trace('Song difficulty could not be loaded.');
-		}
-
-		if (currentChart.stage != null && currentChart.stage != '')
-		{
-			currentStageId = currentChart.stage;
-		}
-		else
-		{
-			currentStageId = SongValidator.DEFAULT_STAGE;
-		}
-
-		loadStage(currentStageId);
-	}
-
-	function initCharacters()
-	{
-		if (currentSong_NEW != null)
-		{
-			initCharacters_NEW();
-			return;
-		}
-
-		iconP1 = new HealthIcon(currentSong.player1, 0);
-		iconP1.y = healthBar.y - (iconP1.height / 2);
-		add(iconP1);
-
-		iconP2 = new HealthIcon(currentSong.player2, 1);
-		iconP2.y = healthBar.y - (iconP2.height / 2);
-		add(iconP2);
-
-		//
-		// GIRLFRIEND
-		//
-
-		// TODO: Tie the GF version to the song data, not the stage ID or the current player.
-		var gfVersion:String = 'gf';
-
-		switch (currentStageId)
-		{
-			case 'pyro' | 'phillyStreets':
-				gfVersion = 'nene';
-			case 'blazin':
-				gfVersion = '';
-			case 'limoRide':
-				gfVersion = 'gf-car';
-			case 'mallXmas' | 'mallEvil':
-				gfVersion = 'gf-christmas';
-			case 'school' | 'schoolEvil':
-				gfVersion = 'gf-pixel';
-			case 'tankmanBattlefield':
-				gfVersion = 'gf-tankmen';
-		}
-
-		if (currentSong.player1 == "pico")
-			gfVersion = "nene";
-
-		if (currentSong.song.toLowerCase() == 'stress')
-			gfVersion = 'pico-speaker';
-
-		if (currentSong.song.toLowerCase() == 'tutorial')
-			gfVersion = '';
-
-		//
-		// GIRLFRIEND
-		//
-		var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(gfVersion);
-
-		if (girlfriend != null)
-		{
-			girlfriend.characterType = CharacterType.GF;
-			girlfriend.scrollFactor.set(0.95, 0.95);
-			if (gfVersion == 'pico-speaker')
-			{
-				girlfriend.x -= 50;
-				girlfriend.y -= 200;
-			}
-		}
-		else if (gfVersion != '')
-		{
-			trace('WARNING: Could not load girlfriend character with ID ${gfVersion}, skipping...');
-		}
-
-		//
-		// DAD
-		//
-		var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player2);
-
-		if (dad != null)
-		{
-			dad.characterType = CharacterType.DAD;
-		}
-
-		switch (currentSong.player2)
-		{
-			case 'gf':
-				if (isStoryMode)
-				{
-					cameraFollowPoint.x += 600;
-					tweenCamIn();
-				}
-		}
-
-		//
-		// BOYFRIEND
-		//
-		var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player1);
-
-		if (boyfriend != null)
-		{
-			boyfriend.characterType = CharacterType.BF;
-		}
-
-		if (currentStage != null)
-		{
-			// We're using Eric's stage handler.
-			// Characters get added to the stage, not the main scene.
-			if (girlfriend != null)
-			{
-				currentStage.addCharacter(girlfriend, GF);
-			}
-
-			if (boyfriend != null)
-			{
-				currentStage.addCharacter(boyfriend, BF);
-			}
-
-			if (dad != null)
-			{
-				currentStage.addCharacter(dad, DAD);
-				// Camera starts at dad.
-				cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y);
-			}
-
-			// Redo z-indexes.
-			currentStage.refresh();
-		}
-	}
-
-	function initCharacters_NEW()
-	{
-		if (currentSong_NEW == null || currentChart == null)
-		{
-			trace('Song difficulty could not be loaded.');
-		}
-
-		// TODO: Switch playable character by manipulating this value.
-		// TODO: How to choose which one to use for story mode?
-
-		var playableChars = currentChart.getPlayableChars();
-		var currentPlayer = 'bf';
-
-		if (playableChars.length == 0)
-		{
-			trace('WARNING: No playable characters found for this song.');
-		}
-		else if (playableChars.indexOf(currentPlayer) == -1)
-		{
-			currentPlayer = playableChars[0];
-		}
-
-		var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayer);
-
-		//
-		// GIRLFRIEND
-		//
-		var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.girlfriend);
-
-		if (girlfriend != null)
-		{
-			girlfriend.characterType = CharacterType.GF;
-		}
-		else if (currentCharData.girlfriend != '')
-		{
-			trace('WARNING: Could not load girlfriend character with ID ${currentCharData.girlfriend}, skipping...');
-		}
-		else
-		{
-			// Chosen GF was '' so we don't load one.
-		}
-
-		//
-		// DAD
-		//
-		var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.opponent);
-
-		if (dad != null)
-		{
-			dad.characterType = CharacterType.DAD;
-		}
-
-		// TODO: Cut out this code/make it generic.
-		switch (currentCharData.opponent)
-		{
-			case 'gf':
-				if (isStoryMode)
-				{
-					cameraFollowPoint.x += 600;
-					tweenCamIn();
-				}
-		}
-
-		//
-		// OPPONENT HEALTH ICON
-		//
-		iconP2 = new HealthIcon(currentCharData.opponent, 1);
-		iconP2.y = healthBar.y - (iconP2.height / 2);
-		add(iconP2);
-
-		//
-		// BOYFRIEND
-		//
-		var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayer);
-
-		if (boyfriend != null)
-		{
-			boyfriend.characterType = CharacterType.BF;
-		}
-
-		//
-		// PLAYER HEALTH ICON
-		//
-		iconP1 = new HealthIcon(currentPlayer, 0);
-		iconP1.y = healthBar.y - (iconP1.height / 2);
-		add(iconP1);
-
-		//
-		// ADD CHARACTERS TO SCENE
-		//
-
-		if (currentStage != null)
-		{
-			// Characters get added to the stage, not the main scene.
-			if (girlfriend != null)
-			{
-				currentStage.addCharacter(girlfriend, GF);
-			}
-
-			if (boyfriend != null)
-			{
-				currentStage.addCharacter(boyfriend, BF);
-			}
-
-			if (dad != null)
-			{
-				currentStage.addCharacter(dad, DAD);
-				// Camera starts at dad.
-				cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y);
-			}
-
-			// Rearrange by z-indexes.
-			currentStage.refresh();
-		}
-	}
-
-	/**
-	 * Removes any references to the current stage, then clears the stage cache,
-	 * then reloads all the stages.
-	 * 
-	 * This is useful for when you want to edit a stage without reloading the whole game.
-	 * Reloading works on both the JSON and the HXC, if applicable.
-	 * 
-	 * Call this by pressing F5 on a debug build.
-	 */
-	override function debug_refreshModules()
-	{
-		// Remove the current stage. If the stage gets deleted while it's still in use,
-		// it'll probably crash the game or something.
-		if (this.currentStage != null)
-		{
-			remove(currentStage);
-			var event:ScriptEvent = new ScriptEvent(ScriptEvent.DESTROY, false);
-			ScriptEventDispatcher.callEvent(currentStage, event);
-			currentStage = null;
-		}
-
-		super.debug_refreshModules();
-	}
-
-	/**
-	 * Pauses music and vocals easily.
-	 */
-	public function pauseMusic()
-	{
-		FlxG.sound.music.pause();
-		vocals.pause();
-	}
-
-	/**
-	 * Loads stage data from cache, assembles the props,
-	 * and adds it to the state.
-	 * @param id 
-	 */
-	function loadStage(id:String)
-	{
-		currentStage = StageDataParser.fetchStage(id);
-
-		if (currentStage != null)
-		{
-			// Actually create and position the sprites.
-			var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
-			ScriptEventDispatcher.callEvent(currentStage, event);
-
-			// Apply camera zoom.
-			defaultCameraZoom = currentStage.camZoom;
-
-			// Add the stage to the scene.
-			this.add(currentStage);
-		}
-	}
-
-	function initDiscord():Void
-	{
-		#if discord_rpc
-		storyDifficultyText = difficultyString();
-		iconRPC = currentSong.player2;
-
-		// To avoid having duplicate images in Discord assets
-		switch (iconRPC)
-		{
-			case 'senpai-angry':
-				iconRPC = 'senpai';
-			case 'monster-christmas':
-				iconRPC = 'monster';
-			case 'mom-car':
-				iconRPC = 'mom';
-		}
-
-		// String that contains the mode defined here so it isn't necessary to call changePresence for each mode
-		detailsText = isStoryMode ? "Story Mode: Week " + storyWeek : "Freeplay";
-		detailsPausedText = "Paused - " + detailsText;
-
-		// Updating Discord Rich Presence.
-		DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
-		#end
-	}
-
-	function schoolIntro(?dialogueBox:DialogueBox):Void
-	{
-		var black:FlxSprite = new FlxSprite(-100, -100).makeGraphic(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
-		black.scrollFactor.set();
-		add(black);
-
-		var red:FlxSprite = new FlxSprite(-100, -100).makeGraphic(FlxG.width * 2, FlxG.height * 2, 0xFFff1b31);
-		red.scrollFactor.set();
-
-		var senpaiEvil:FlxSprite = new FlxSprite();
-		senpaiEvil.frames = Paths.getSparrowAtlas('weeb/senpaiCrazy');
-		senpaiEvil.animation.addByPrefix('idle', 'Senpai Pre Explosion', 24, false);
-		senpaiEvil.setGraphicSize(Std.int(senpaiEvil.width * Constants.PIXEL_ART_SCALE));
-		senpaiEvil.scrollFactor.set();
-		senpaiEvil.updateHitbox();
-		senpaiEvil.screenCenter();
-		senpaiEvil.x += senpaiEvil.width / 5;
-
-		if (currentSong.song.toLowerCase() == 'roses' || currentSong.song.toLowerCase() == 'thorns')
-		{
-			remove(black);
-
-			if (currentSong.song.toLowerCase() == 'thorns')
-			{
-				add(red);
-				camHUD.visible = false;
-			}
-			else
-				FlxG.sound.play(Paths.sound('ANGRY'));
-			// moved senpai angry noise in here to clean up cutscene switch case lol
-		}
-
-		new FlxTimer().start(0.3, function(tmr:FlxTimer)
-		{
-			black.alpha -= 0.15;
-
-			if (black.alpha > 0)
-				tmr.reset(0.3);
-			else
-			{
-				if (dialogueBox != null)
-				{
-					isInCutscene = true;
-
-					if (currentSong.song.toLowerCase() == 'thorns')
-					{
-						add(senpaiEvil);
-						senpaiEvil.alpha = 0;
-						new FlxTimer().start(0.3, function(swagTimer:FlxTimer)
-						{
-							senpaiEvil.alpha += 0.15;
-							if (senpaiEvil.alpha < 1)
-								swagTimer.reset();
-							else
-							{
-								senpaiEvil.animation.play('idle');
-								FlxG.sound.play(Paths.sound('Senpai_Dies'), 1, false, null, true, function()
-								{
-									remove(senpaiEvil);
-									remove(red);
-									FlxG.camera.fade(FlxColor.WHITE, 0.01, true, function()
-									{
-										add(dialogueBox);
-										camHUD.visible = true;
-									}, true);
-								});
-								new FlxTimer().start(3.2, function(deadTime:FlxTimer)
-								{
-									FlxG.camera.fade(FlxColor.WHITE, 1.6, false);
-								});
-							}
-						});
-					}
-					else
-						add(dialogueBox);
-				}
-				else
-					startCountdown();
-
-				remove(black);
-			}
-		});
-	}
-
-	function startSong():Void
-	{
-		dispatchEvent(new ScriptEvent(ScriptEvent.SONG_START));
-
-		startingSong = false;
-
-		previousFrameTime = FlxG.game.ticks;
-
-		if (!isGamePaused)
-		{
-			// if (FlxG.sound.music != null)
-			// FlxG.sound.music.play(true);
-			// else
-			if (currentChart != null)
-			{
-				currentChart.playInst(1.0, false);
-			}
-			else
-			{
-				FlxG.sound.playMusic(Paths.inst(currentSong.song), 1, false);
-			}
-		}
-
-		FlxG.sound.music.onComplete = endSong;
-		trace('Playing vocals...');
-		vocals.play();
-
-		#if discord_rpc
-		// Song duration in a float, useful for the time left feature
-		songLength = FlxG.sound.music.length;
-
-		// Updating Discord Rich Presence (with Time Left)
-		DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, songLength);
-		#end
-	}
-
-	private function generateSong():Void
-	{
-		// FlxG.log.add(ChartParser.parse());
-
-		Conductor.forceBPM(currentSong.bpm);
-
-		currentSong.song = currentSong.song;
-
-		if (currentSong.needsVoices)
-			vocals = VoicesGroup.build(currentSong.song, currentSong.voiceList);
-		else
-			vocals = VoicesGroup.build(currentSong.song, null);
-
-		vocals.members[0].onComplete = function()
-		{
-			vocalsFinished = true;
-		};
-
-		trace(vocals);
-
-		activeNotes = new FlxTypedGroup<Note>();
-		activeNotes.zIndex = 1000;
-		add(activeNotes);
-
-		regenNoteData();
-
-		generatedMusic = true;
-	}
-
-	private function generateSong_NEW():Void
-	{
-		if (currentChart == null)
-		{
-			trace('Song difficulty could not be loaded.');
-		}
-
-		Conductor.forceBPM(currentChart.getStartingBPM());
-
-		// TODO: Fix grouped vocals
-		vocals = currentChart.buildVocals();
-		vocals.members[0].onComplete = function()
-		{
-			vocalsFinished = true;
-		}
-
-		// Create the rendered note group.
-		activeNotes = new FlxTypedGroup<Note>();
-		activeNotes.zIndex = 1000;
-		add(activeNotes);
-
-		regenNoteData_NEW();
-
-		generatedMusic = true;
-	}
-
-	function regenNoteData():Void
-	{
-		// resets combo, should prob put somewhere else!
-		Highscore.tallies.combo = 0;
-		Highscore.tallies = new Tallies();
-		// make unspawn notes shit def empty
-		inactiveNotes = [];
-
-		activeNotes.forEach(function(nt)
-		{
-			nt.followsTime = false;
-			FlxTween.tween(nt, {y: FlxG.height + nt.y}, 0.5, {
-				ease: FlxEase.expoIn,
-				onComplete: function(twn)
-				{
-					nt.kill();
-					activeNotes.remove(nt, true);
-					nt.destroy();
-				}
-			});
-		});
-
-		var noteData:Array<SwagSection>;
-
-		// NEW SHIT
-		noteData = SongLoad.getSong();
-
-		for (section in noteData)
-		{
-			for (songNotes in section.sectionNotes)
-			{
-				var daStrumTime:Float = songNotes.strumTime;
-				// TODO: Replace 4 with strumlineSize
-				var daNoteData:Int = Std.int(songNotes.noteData % 4);
-				var gottaHitNote:Bool = section.mustHitSection;
-
-				if (songNotes.highStakes) // noteData > 3
-					gottaHitNote = !section.mustHitSection;
-
-				var oldNote:Note;
-				if (inactiveNotes.length > 0)
-					oldNote = inactiveNotes[Std.int(inactiveNotes.length - 1)];
-				else
-					oldNote = null;
-
-				var strumlineStyle:StrumlineStyle = NORMAL;
-
-				// TODO: Put this in the chart or something?
-				switch (currentStageId)
-				{
-					case 'school':
-						strumlineStyle = PIXEL;
-					case 'schoolEvil':
-						strumlineStyle = PIXEL;
-				}
-
-				var swagNote:Note = new Note(daStrumTime, daNoteData, oldNote, false, strumlineStyle);
-				// swagNote.data = songNotes;
-				swagNote.data.sustainLength = songNotes.sustainLength;
-				swagNote.data.noteKind = songNotes.noteKind;
-				swagNote.scrollFactor.set(0, 0);
-
-				var susLength:Float = swagNote.data.sustainLength;
-
-				susLength = susLength / Conductor.stepCrochet;
-				inactiveNotes.push(swagNote);
-
-				for (susNote in 0...Math.round(susLength))
-				{
-					oldNote = inactiveNotes[Std.int(inactiveNotes.length - 1)];
-
-					var sustainNote:Note = new Note(daStrumTime + (Conductor.stepCrochet * susNote) + Conductor.stepCrochet, daNoteData, oldNote, true,
-						strumlineStyle);
-					sustainNote.data.noteKind = songNotes.noteKind;
-					sustainNote.scrollFactor.set();
-					inactiveNotes.push(sustainNote);
-
-					sustainNote.mustPress = gottaHitNote;
-
-					if (sustainNote.mustPress)
-						sustainNote.x += FlxG.width / 2; // general offset
-				}
-
-				// TODO: Replace 4 with strumlineSize
-				swagNote.mustPress = gottaHitNote;
-
-				if (swagNote.mustPress)
-				{
-					if (playerStrumline != null)
-					{
-						swagNote.x = playerStrumline.getArrow(swagNote.data.noteData).x;
-					}
-					else
-					{
-						swagNote.x += FlxG.width / 2; // general offset
-					}
-				}
-				else
-				{
-					if (enemyStrumline != null)
-					{
-						swagNote.x = enemyStrumline.getArrow(swagNote.data.noteData).x;
-					}
-					else
-					{
-						// swagNote.x += FlxG.width / 2; // general offset
-					}
-				}
-			}
-		}
-
-		inactiveNotes.sort(function(a:Note, b:Note):Int
-		{
-			return SortUtil.byStrumtime(FlxSort.ASCENDING, a, b);
-		});
-	}
-
-	function regenNoteData_NEW():Void
-	{
-		Highscore.tallies.combo = 0;
-		Highscore.tallies = new Tallies();
-
-		// Reset song events.
-		songEvents = currentChart.getEvents();
-		SongEventHandler.resetEvents(songEvents);
-
-		// Destroy inactive notes.
-		inactiveNotes = [];
-
-		// Destroy active notes.
-		activeNotes.forEach(function(nt)
-		{
-			nt.followsTime = false;
-			FlxTween.tween(nt, {y: FlxG.height + nt.y}, 0.5, {
-				ease: FlxEase.expoIn,
-				onComplete: function(twn)
-				{
-					nt.kill();
-					activeNotes.remove(nt, true);
-					nt.destroy();
-				}
-			});
-		});
-
-		var noteData:Array<SongNoteData> = currentChart.notes;
-
-		var oldNote:Note = null;
-		for (songNote in noteData)
-		{
-			var mustHitNote:Bool = songNote.getMustHitNote();
-
-			// TODO: Put this in the chart or something?
-			var strumlineStyle:StrumlineStyle = null;
-			switch (currentStageId)
-			{
-				case 'school':
-					strumlineStyle = PIXEL;
-				case 'schoolEvil':
-					strumlineStyle = PIXEL;
-				default:
-					strumlineStyle = NORMAL;
-			}
-
-			var newNote:Note = new Note(songNote.time, songNote.data, oldNote, false, strumlineStyle);
-			newNote.mustPress = mustHitNote;
-			newNote.data.sustainLength = songNote.length;
-			newNote.data.noteKind = songNote.kind;
-			newNote.scrollFactor.set(0, 0);
-
-			// Note positioning.
-			// TODO: Make this more robust.
-			if (newNote.mustPress)
-			{
-				if (playerStrumline != null)
-				{
-					// Align with the strumline arrow.
-					newNote.x = playerStrumline.getArrow(songNote.getDirection()).x;
-				}
-				else
-				{
-					// Assume strumline position.
-					newNote.x += FlxG.width / 2;
-				}
-			}
-			else
-			{
-				if (enemyStrumline != null)
-				{
-					newNote.x = enemyStrumline.getArrow(songNote.getDirection()).x;
-				}
-				else
-				{
-					// newNote.x += 0;
-				}
-			}
-
-			inactiveNotes.push(newNote);
-
-			oldNote = newNote;
-
-			// Generate X sustain notes.
-			var sustainSections = Math.round(songNote.length / Conductor.stepCrochet);
-			for (noteIndex in 0...sustainSections)
-			{
-				var noteTimeOffset:Float = Conductor.stepCrochet + (Conductor.stepCrochet * noteIndex);
-				var sustainNote:Note = new Note(songNote.time + noteTimeOffset, songNote.data, oldNote, true, strumlineStyle);
-				sustainNote.mustPress = mustHitNote;
-				sustainNote.data.noteKind = songNote.kind;
-				sustainNote.scrollFactor.set(0, 0);
-
-				if (sustainNote.mustPress)
-				{
-					if (playerStrumline != null)
-					{
-						// Align with the strumline arrow.
-						sustainNote.x = playerStrumline.getArrow(songNote.getDirection()).x;
-					}
-					else
-					{
-						// Assume strumline position.
-						sustainNote.x += FlxG.width / 2;
-					}
-				}
-				else
-				{
-					if (enemyStrumline != null)
-					{
-						sustainNote.x = enemyStrumline.getArrow(songNote.getDirection()).x;
-					}
-					else
-					{
-						// newNote.x += 0;
-					}
-				}
-
-				inactiveNotes.push(sustainNote);
-
-				oldNote = sustainNote;
-			}
-		}
-
-		// Sorting is an expensive operation.
-		// Assume it was done in the chart file.
-		/**
-			inactiveNotes.sort(function(a:Note, b:Note):Int
-			{
-				return SortUtil.byStrumtime(FlxSort.ASCENDING, a, b);
-			});
-		**/
-	}
-
-	function tweenCamIn():Void
-	{
-		FlxTween.tween(FlxG.camera, {zoom: 1.3 * FlxCamera.defaultZoom}, (Conductor.stepCrochet * 4 / 1000), {ease: FlxEase.elasticInOut});
-	}
-
-	#if discord_rpc
-	override public function onFocus():Void
-	{
-		if (health > 0 && !paused && FlxG.autoPause)
-		{
-			if (Conductor.songPosition > 0.0)
-				DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true,
-					songLength - Conductor.songPosition);
-			else
-				DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
-		}
-
-		super.onFocus();
-	}
-
-	override public function onFocusLost():Void
-	{
-		if (health > 0 && !paused && FlxG.autoPause)
-			DiscordClient.changePresence(detailsPausedText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
-
-		super.onFocusLost();
-	}
-	#end
-
-	function resyncVocals():Void
-	{
-		if (_exiting || vocals == null)
-			return;
-
-		vocals.pause();
-
-		FlxG.sound.music.play();
-		Conductor.update(FlxG.sound.music.time + Conductor.offset);
-
-		if (vocalsFinished)
-			return;
-
-		vocals.time = FlxG.sound.music.time;
-		vocals.play();
-	}
-
-	override public function update(elapsed:Float)
-	{
-		super.update(elapsed);
-
-		if (criticalFailure)
-			return;
-
-		if (FlxG.keys.justPressed.U)
-		{
-			// hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!!
-			persistentUpdate = false;
-			openSubState(new StageOffsetSubstate());
-		}
-
-		updateHealthBar();
-		updateScoreText();
-
-		if (needsReset)
-		{
-			dispatchEvent(new ScriptEvent(ScriptEvent.SONG_RETRY));
-
-			resetCamera();
-
-			persistentUpdate = true;
-			persistentDraw = true;
-
-			startingSong = true;
-
-			FlxG.sound.music.pause();
-			vocals.pause();
-
-			FlxG.sound.music.time = 0;
-
-			currentStage.resetStage();
-
-			// Delete all notes and reset the arrays.
-			if (currentChart != null)
-			{
-				regenNoteData_NEW();
-			}
-			else
-			{
-				regenNoteData();
-			}
-
-			health = 1;
-			songScore = 0;
-			Highscore.tallies.combo = 0;
-			Countdown.performCountdown(currentStageId.startsWith('school'));
-
-			needsReset = false;
-		}
-
-		#if !debug
-		perfectMode = false;
-		#else
-		if (FlxG.keys.justPressed.H)
-			camHUD.visible = !camHUD.visible;
-		#end
-
-		// do this BEFORE super.update() so songPosition is accurate
-		if (startingSong)
-		{
-			if (isInCountdown)
-			{
-				Conductor.songPosition += elapsed * 1000;
-				if (Conductor.songPosition >= 0)
-					startSong();
-			}
-		}
-		else
-		{
-			if (Paths.SOUND_EXT == 'mp3')
-				Conductor.offset = -13; // DO NOT FORGET TO REMOVE THE HARDCODE! WHEN I MAKE BETTER OFFSET SYSTEM!
-
-			Conductor.update(FlxG.sound.music.time + Conductor.offset);
-
-			if (!isGamePaused)
-			{
-				songTime += FlxG.game.ticks - previousFrameTime;
-				previousFrameTime = FlxG.game.ticks;
-
-				// Interpolation type beat
-				if (Conductor.lastSongPos != Conductor.songPosition)
-				{
-					songTime = (songTime + Conductor.songPosition) / 2;
-					Conductor.lastSongPos = Conductor.songPosition;
-				}
-			}
-		}
-
-		var androidPause:Bool = false;
-
-		#if android
-		androidPause = FlxG.android.justPressed.BACK;
-		#end
-
-		if ((controls.PAUSE || androidPause) && isInCountdown && mayPauseGame)
-		{
-			var event = new PauseScriptEvent(FlxG.random.bool(1 / 1000));
-
-			dispatchEvent(event);
-
-			if (!event.eventCanceled)
-			{
-				// Pause updates while the substate is open, preventing the game state from advancing.
-				persistentUpdate = false;
-				// Enable drawing while the substate is open, allowing the game state to be shown behind the pause menu.
-				persistentDraw = true;
-
-				// There is a 1/1000 change to use a special pause menu.
-				// This prevents the player from resuming, but that's the point.
-				// It's a reference to Gitaroo Man, which doesn't let you pause the game.
-				if (event.gitaroo)
-				{
-					FlxG.switchState(new GitarooPause());
-				}
-				else
-				{
-					var boyfriendPos = currentStage.getBoyfriend().getScreenPosition();
-					var pauseSubState = new PauseSubState(boyfriendPos.x, boyfriendPos.y);
-					openSubState(pauseSubState);
-					pauseSubState.camera = camHUD;
-					boyfriendPos.put();
-				}
-
-				#if discord_rpc
-				DiscordClient.changePresence(detailsPausedText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
-				#end
-			}
-		}
-
-		#if debug
-		// 1: End the song immediately.
-		if (FlxG.keys.justPressed.ONE)
-			endSong();
-
-		// 2: Gain 10% health.
-		if (FlxG.keys.justPressed.TWO)
-			health += 0.1 * 2.0;
-
-		// 3: Lose 5% health.
-		if (FlxG.keys.justPressed.THREE)
-			health -= 0.05 * 2.0;
-		#end
-
-		// 7: Move to the charter.
-		if (FlxG.keys.justPressed.SEVEN)
-		{
-			FlxG.switchState(new ChartingState());
-
-			#if discord_rpc
-			DiscordClient.changePresence("Chart Editor", null, null, true);
-			#end
-		}
-
-		// 8: Move to the offset editor.
-		if (FlxG.keys.justPressed.EIGHT)
-			FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
-
-		// 9: Toggle the old icon.
-		if (FlxG.keys.justPressed.NINE)
-			iconP1.toggleOldIcon();
-
-		#if debug
-		// PAGEUP: Skip forward one section.
-		// SHIFT+PAGEUP: Skip forward ten sections.
-		if (FlxG.keys.justPressed.PAGEUP)
-			changeSection(FlxG.keys.pressed.SHIFT ? 10 : 1);
-		// PAGEDOWN: Skip backward one section. Doesn't replace notes.
-		// SHIFT+PAGEDOWN: Skip backward ten sections.
-		if (FlxG.keys.justPressed.PAGEDOWN)
-			changeSection(FlxG.keys.pressed.SHIFT ? -10 : -1);
-		#end
-
-		if (health > 2.0)
-			health = 2.0;
-		if (health < 0.0)
-			health = 0.0;
-
-		if (camZooming && subState == null)
-		{
-			FlxG.camera.zoom = FlxMath.lerp(defaultCameraZoom, FlxG.camera.zoom, 0.95);
-			camHUD.zoom = FlxMath.lerp(1 * FlxCamera.defaultZoom, camHUD.zoom, 0.95);
-		}
-
-		FlxG.watch.addQuick("beatShit", Conductor.currentBeat);
-		FlxG.watch.addQuick("stepShit", Conductor.currentStep);
-		if (currentStage != null)
-		{
-			FlxG.watch.addQuick("bfAnim", currentStage.getBoyfriend().getCurrentAnimation());
-		}
-		FlxG.watch.addQuick("songPos", Conductor.songPosition);
-
-		if (currentSong != null && currentSong.song == 'Fresh')
-		{
-			switch (Conductor.currentBeat)
-			{
-				case 16:
-					camZooming = true;
-					gfSpeed = 2;
-				case 48:
-					gfSpeed = 1;
-				case 80:
-					gfSpeed = 2;
-				case 112:
-					gfSpeed = 1;
-			}
-		}
-
-		if (!isInCutscene && !_exiting)
-		{
-			// RESET = Quick Game Over Screen
-			if (controls.RESET)
-			{
-				health = 0;
-				trace("RESET = True");
-			}
-
-			#if CAN_CHEAT // brandon's a pussy
-			if (controls.CHEAT)
-			{
-				health += 1;
-				trace("User is cheating!");
-			}
-			#end
-
-			if (health <= 0 && !isPracticeMode)
-			{
-				vocals.pause();
-				FlxG.sound.music.pause();
-
-				deathCounter += 1;
-
-				dispatchEvent(new ScriptEvent(ScriptEvent.GAME_OVER));
-
-				// Disable updates, preventing animations in the background from playing.
-				persistentUpdate = false;
-				#if debug
-				if (FlxG.keys.pressed.THREE)
-				{
-					// TODO: Change the key or delete this?
-					// In debug builds, pressing 3 to kill the player makes the background transparent.
-					persistentDraw = true;
-				}
-				else
-				{
-				#end
-					persistentDraw = false;
-				#if debug
-				}
-				#end
-
-				var gameOverSubstate = new GameOverSubstate();
-				openSubState(gameOverSubstate);
-
-				#if discord_rpc
-				// Game Over doesn't get his own variable because it's only used here
-				DiscordClient.changePresence("Game Over - " + detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
-				#end
-			}
-		}
-
-		while (inactiveNotes[0] != null && inactiveNotes[0].data.strumTime - Conductor.songPosition < 1800 / SongLoad.getSpeed())
-		{
-			var dunceNote:Note = inactiveNotes[0];
-
-			if (dunceNote.mustPress && !dunceNote.isSustainNote)
-				Highscore.tallies.totalNotes++;
-
-			activeNotes.add(dunceNote);
-
-			inactiveNotes.shift();
-		}
-
-		if (generatedMusic && playerStrumline != null)
-		{
-			activeNotes.forEachAlive(function(daNote:Note)
-			{
-				if ((PreferencesMenu.getPref('downscroll') && daNote.y < -daNote.height)
-					|| (!PreferencesMenu.getPref('downscroll') && daNote.y > FlxG.height))
-				{
-					daNote.active = false;
-					daNote.visible = false;
-				}
-				else
-				{
-					daNote.visible = true;
-					daNote.active = true;
-				}
-
-				var strumLineMid = playerStrumline.y + Note.swagWidth / 2;
-
-				if (daNote.followsTime)
-					daNote.y = (Conductor.songPosition - daNote.data.strumTime) * (0.45 * FlxMath.roundDecimal(SongLoad.getSpeed(),
-						2) * daNote.noteSpeedMulti);
-
-				if (PreferencesMenu.getPref('downscroll'))
-				{
-					daNote.y += playerStrumline.y;
-					if (daNote.isSustainNote)
-					{
-						if (daNote.animation.curAnim.name.endsWith("end") && daNote.prevNote != null)
-							daNote.y += daNote.prevNote.height;
-						else
-							daNote.y += daNote.height / 2;
-
-						if ((!daNote.mustPress || (daNote.wasGoodHit || (daNote.prevNote.wasGoodHit && !daNote.canBeHit)))
-							&& daNote.y - daNote.offset.y * daNote.scale.y + daNote.height >= strumLineMid)
-						{
-							applyClipRect(daNote);
-						}
-					}
-				}
-				else
-				{
-					if (daNote.followsTime)
-						daNote.y = playerStrumline.y - daNote.y;
-					if (daNote.isSustainNote
-						&& (!daNote.mustPress || (daNote.wasGoodHit || (daNote.prevNote.wasGoodHit && !daNote.canBeHit)))
-						&& daNote.y + daNote.offset.y * daNote.scale.y <= strumLineMid)
-					{
-						applyClipRect(daNote);
-					}
-				}
-
-				if (!daNote.mustPress && daNote.wasGoodHit && !daNote.tooLate)
-				{
-					if (currentSong != null && currentSong.song != 'Tutorial')
-						camZooming = true;
-
-					var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, Highscore.tallies.combo, true);
-					dispatchEvent(event);
-
-					// Calling event.cancelEvent() in a module should force the CPU to miss the note.
-					// This is useful for cool shit, including but not limited to:
-					// - Making the AI ignore notes which are hazardous.
-					// - Making the AI miss notes on purpose for aesthetic reasons.
-					if (event.eventCanceled)
-					{
-						daNote.tooLate = true;
-					}
-					else
-					{
-						// Volume of DAD.
-						if (currentSong != null && currentSong.needsVoices)
-							vocals.volume = 1;
-					}
-				}
-
-				// WIP interpolation shit? Need to fix the pause issue
-				// daNote.y = (strumLine.y - (songTime - daNote.strumTime) * (0.45 * SONG.speed[SongLoad.curDiff]));
-
-				// removing this so whether the note misses or not is entirely up to Note class
-				// var noteMiss:Bool = daNote.y < -daNote.height;
-
-				// if (PreferencesMenu.getPref('downscroll'))
-				// noteMiss = daNote.y > FlxG.height;
-
-				if (daNote.isSustainNote && daNote.wasGoodHit)
-				{
-					if ((!PreferencesMenu.getPref('downscroll') && daNote.y < -daNote.height)
-						|| (PreferencesMenu.getPref('downscroll') && daNote.y > FlxG.height))
-					{
-						daNote.active = false;
-						daNote.visible = false;
-
-						daNote.kill();
-						activeNotes.remove(daNote, true);
-						daNote.destroy();
-					}
-				}
-				if (daNote.wasGoodHit)
-				{
-					daNote.active = false;
-					daNote.visible = false;
-
-					daNote.kill();
-					activeNotes.remove(daNote, true);
-					daNote.destroy();
-				}
-
-				if (daNote.tooLate)
-				{
-					noteMiss(daNote);
-				}
-			});
-		}
-
-		if (songEvents != null && songEvents.length > 0)
-		{
-			var songEventsToActivate:Array<SongEvent> = SongEventHandler.queryEvents(songEvents, Conductor.songPosition);
-
-			if (songEventsToActivate.length > 0)
-				trace('[EVENTS] Found ${songEventsToActivate.length} event(s) to activate.');
-
-			SongEventHandler.activateEvents(songEventsToActivate);
-		}
-
-		if (!isInCutscene)
-			keyShit(true);
-	}
-
-	function applyClipRect(daNote:Note):Void
-	{
-		// clipRect is applied to graphic itself so use frame Heights
-		var swagRect:FlxRect = new FlxRect(0, 0, daNote.frameWidth, daNote.frameHeight);
-		var strumLineMid = playerStrumline.y + Note.swagWidth / 2;
-
-		if (PreferencesMenu.getPref('downscroll'))
-		{
-			swagRect.height = (strumLineMid - daNote.y) / daNote.scale.y;
-			swagRect.y = daNote.frameHeight - swagRect.height;
-		}
-		else
-		{
-			swagRect.y = (strumLineMid - daNote.y) / daNote.scale.y;
-			swagRect.height -= swagRect.y;
-		}
-
-		daNote.clipRect = swagRect;
-	}
-
-	function killCombo():Void
-	{
-		// Girlfriend gets sad if you combo break after hitting 5 notes.
-		if (currentStage != null && currentStage.getGirlfriend() != null)
-			if (Highscore.tallies.combo > 5 && currentStage.getGirlfriend().hasAnimation('sad'))
-				currentStage.getGirlfriend().playAnimation('sad');
-
-		if (Highscore.tallies.combo != 0)
-		{
-			Highscore.tallies.combo = comboPopUps.displayCombo(0);
-		}
-	}
-
-	#if debug
-	/**
-	 * Jumps forward or backward a number of sections in the song.
-	 * Accounts for BPM changes, does not prevent death from skipped notes.
-	 * @param sec 
-	 */
-	function changeSection(sec:Int):Void
-	{
-		FlxG.sound.music.pause();
-
-		var daBPM:Float = currentSong.bpm;
-		var daPos:Float = 0;
-		for (i in 0...(Std.int(curStep / 16 + sec)))
-		{
-			var section = SongLoad.getSong()[i];
-			if (section == null)
-				continue;
-			if (section.changeBPM)
-			{
-				daBPM = SongLoad.getSong()[i].bpm;
-			}
-			daPos += 4 * (1000 * 60 / daBPM);
-		}
-		Conductor.songPosition = FlxG.sound.music.time = daPos;
-		Conductor.songPosition += Conductor.offset;
-		updateCurStep();
-		resyncVocals();
-	}
-	#end
-
-	function endSong():Void
-	{
-		dispatchEvent(new ScriptEvent(ScriptEvent.SONG_END));
-
-		seenCutscene = false;
-		deathCounter = 0;
-		mayPauseGame = false;
-		FlxG.sound.music.volume = 0;
-		vocals.volume = 0;
-		if (currentSong != null && currentSong.validScore)
-		{
-			// crackhead double thingie, sets whether was new highscore, AND saves the song!
-			Highscore.tallies.isNewHighscore = Highscore.saveScore(currentSong.song, songScore, storyDifficulty);
-
-			Highscore.saveCompletion(currentSong.song, Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, storyDifficulty);
-		}
-
-		if (isStoryMode)
-		{
-			campaignScore += songScore;
-
-			storyPlaylist.remove(storyPlaylist[0]);
-
-			if (storyPlaylist.length <= 0)
-			{
-				FlxG.sound.playMusic(Paths.music('freakyMenu'));
-
-				transIn = FlxTransitionableState.defaultTransIn;
-				transOut = FlxTransitionableState.defaultTransOut;
-
-				switch (storyWeek)
-				{
-					case 7:
-						FlxG.switchState(new VideoState());
-					default:
-						FlxG.switchState(new StoryMenuState());
-				}
-
-				// if ()
-				StoryMenuState.weekUnlocked[Std.int(Math.min(storyWeek + 1, StoryMenuState.weekUnlocked.length - 1))] = true;
-
-				if (currentSong.validScore)
-				{
-					NGio.unlockMedal(60961);
-					Highscore.saveWeekScore(storyWeek, campaignScore, storyDifficulty);
-				}
-
-				FlxG.save.data.weekUnlocked = StoryMenuState.weekUnlocked;
-				FlxG.save.flush();
-			}
-			else
-			{
-				var difficulty:String = "";
-
-				if (storyDifficulty == 0)
-					difficulty = '-easy';
-
-				if (storyDifficulty == 2)
-					difficulty = '-hard';
-
-				trace('LOADING NEXT SONG');
-				trace(storyPlaylist[0].toLowerCase() + difficulty);
-
-				FlxTransitionableState.skipNextTransIn = true;
-				FlxTransitionableState.skipNextTransOut = true;
-
-				FlxG.sound.music.stop();
-				vocals.stop();
-
-				if (currentSong.song.toLowerCase() == 'eggnog')
-				{
-					var blackShit:FlxSprite = new FlxSprite(-FlxG.width * FlxG.camera.zoom,
-						-FlxG.height * FlxG.camera.zoom).makeGraphic(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK);
-					blackShit.scrollFactor.set();
-					add(blackShit);
-					camHUD.visible = false;
-					isInCutscene = true;
-
-					FlxG.sound.play(Paths.sound('Lights_Shut_off'), function()
-					{
-						// no camFollow so it centers on horror tree
-						currentSong = SongLoad.loadFromJson(storyPlaylist[0].toLowerCase() + difficulty, storyPlaylist[0]);
-						LoadingState.loadAndSwitchState(new PlayState());
-					});
-				}
-				else
-				{
-					previousCameraFollowPoint = cameraFollowPoint;
-
-					currentSong = SongLoad.loadFromJson(storyPlaylist[0].toLowerCase() + difficulty, storyPlaylist[0]);
-					LoadingState.loadAndSwitchState(new PlayState());
-				}
-			}
-		}
-		else
-		{
-			trace('WENT TO RESULTS SCREEN!');
-			// unloadAssets();
-
-			camZooming = false;
-
-			FlxG.camera.follow(PlayState.instance.currentStage.getGirlfriend(), null, 0.05);
-			FlxG.camera.targetOffset.y -= 350;
-			FlxG.camera.targetOffset.x += 20;
-
-			FlxTween.tween(camHUD, {alpha: 0}, 0.6);
-
-			new FlxTimer().start(0.8, _ ->
-			{
-				currentStage.getGirlfriend().animation.play("cheer");
-
-				FlxTween.tween(FlxG.camera, {zoom: 1200}, 1.1, {
-					ease: FlxEase.expoIn,
-					onComplete: _ ->
-					{
-						persistentUpdate = false;
-						vocals.stop();
-						camHUD.alpha = 1;
-						var res:ResultState = new ResultState();
-						res.camera = camHUD;
-						openSubState(res);
-					}
-				});
-			});
-			// FlxG.switchState(new FreeplayState());
-		}
-	}
-
-	// gives score and pops up rating
-	private function popUpScore(strumtime:Float, daNote:Note):Void
-	{
-		var noteDiff:Float = Math.abs(strumtime - Conductor.songPosition);
-		// boyfriend.playAnimation('hey');
-		vocals.volume = 1;
-
-		var isSick:Bool = false;
-		var score = Scoring.scoreNote(noteDiff, PBOT1);
-		var daRating = Scoring.judgeNote(noteDiff, PBOT1);
-		var healthMulti:Float = daNote.lowStakes ? 0.002 : 0.033;
-
-		if (noteDiff > Note.HIT_WINDOW * Note.BAD_THRESHOLD)
-		{
-			healthMulti *= 0; // no health on shit note
-			daRating = 'shit';
-			Highscore.tallies.shit += 1;
-			score = 50;
-		}
-		else if (noteDiff > Note.HIT_WINDOW * Note.GOOD_THRESHOLD)
-		{
-			healthMulti *= 0.2;
-			daRating = 'bad';
-			Highscore.tallies.bad += 1;
-		}
-		else if (noteDiff > Note.HIT_WINDOW * Note.SICK_THRESHOLD)
-		{
-			healthMulti *= 0.78;
-			daRating = 'good';
-			Highscore.tallies.good += 1;
-			score = 200;
-		}
-		else
-		{
-			isSick = true;
-		}
-
-		health += healthMulti;
-		if (isSick)
-		{
-			Highscore.tallies.sick += 1;
-			var noteSplash:NoteSplash = grpNoteSplashes.recycle(NoteSplash);
-			noteSplash.setupNoteSplash(daNote.x, daNote.y, daNote.data.noteData);
-			// new NoteSplash(daNote.x, daNote.y, daNote.noteData);
-			grpNoteSplashes.add(noteSplash);
-		}
-		// Only add the score if you're not on practice mode
-		if (!isPracticeMode)
-			songScore += score;
-		comboPopUps.displayRating(daRating);
-		if (Highscore.tallies.combo >= 10 || Highscore.tallies.combo == 0)
-			comboPopUps.displayCombo(Highscore.tallies.combo);
-	}
-
-	/*
-		function controlCamera()
-		{
-			if (currentStage == null)
-				return;
-
-			switch (cameraFocusCharacter)
-			{
-				default: // null = No change
-					break;
-				case 0: // Boyfriend
-					var isFocusedOnBF = cameraFollowPoint.x == currentStage.getBoyfriend().cameraFocusPoint.x;
-					if (!isFocusedOnBF)
-					{
-						// Focus the camera on the player.
-						cameraFollowPoint.setPosition(currentStage.getBoyfriend().cameraFocusPoint.x, currentStage.getBoyfriend().cameraFocusPoint.y);
-					}
-				case 1: // Dad
-					var isFocusedOnDad = cameraFollowPoint.x == currentStage.getDad().cameraFocusPoint.x;
-					if (!isFocusedOnDad)
-					{
-						cameraFollowPoint.setPosition(currentStage.getDad().cameraFocusPoint.x, currentStage.getDad().cameraFocusPoint.y);
-					}
-				case 2: // Girlfriend
-					var isFocusedOnGF = cameraFollowPoint.x == currentStage.getGirlfriend().cameraFocusPoint.x;
-					if (!isFocusedOnGF)
-					{
-						cameraFollowPoint.setPosition(currentStage.getGirlfriend().cameraFocusPoint.x, currentStage.getGirlfriend().cameraFocusPoint.y);
-					}
-			}
-
-			/*
-				if (cameraRightSide && !isFocusedOnBF)
-				{
-					// Focus the camera on the player.
-					cameraFollowPoint.setPosition(currentStage.getBoyfriend().cameraFocusPoint.x, currentStage.getBoyfriend().cameraFocusPoint.y);
-
-					// TODO: Un-hardcode this.
-					if (currentSong.song.toLowerCase() == 'tutorial')
-						FlxTween.tween(FlxG.camera, {zoom: 1 * FlxCamera.defaultZoom}, (Conductor.stepCrochet * 4 / 1000), {ease: FlxEase.elasticInOut});
-				}
-				else if (!cameraRightSide && !isFocusedOnDad)
-				{
-					// Focus the camera on the opponent.
-					cameraFollowPoint.setPosition(currentStage.getDad().cameraFocusPoint.x, currentStage.getDad().cameraFocusPoint.y);
-
-					// TODO: Un-hardcode this stuff.
-					if (currentStage.getDad().characterId == 'mom')
-					{
-						vocals.volume = 1;
-					}
-
-					if (currentSong.song.toLowerCase() == 'tutorial')
-						tweenCamIn();
-				}
-	 */
-	// }
-
-	public function keyShit(test:Bool):Void
-	{
-		if (PlayState.instance == null)
-			return;
-
-		// control arrays, order L D R U
-		var holdArray:Array<Bool> = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT];
-		var pressArray:Array<Bool> = [
-			controls.NOTE_LEFT_P,
-			controls.NOTE_DOWN_P,
-			controls.NOTE_UP_P,
-			controls.NOTE_RIGHT_P
-		];
-		var releaseArray:Array<Bool> = [
-			controls.NOTE_LEFT_R,
-			controls.NOTE_DOWN_R,
-			controls.NOTE_UP_R,
-			controls.NOTE_RIGHT_R
-		];
-		// HOLDS, check for sustain notes
-		if (holdArray.contains(true) && PlayState.instance.generatedMusic)
-		{
-			PlayState.instance.activeNotes.forEachAlive(function(daNote:Note)
-			{
-				if (daNote.isSustainNote && daNote.canBeHit && daNote.mustPress && holdArray[daNote.data.noteData])
-					PlayState.instance.goodNoteHit(daNote);
-			});
-		}
-
-		// PRESSES, check for note hits
-		if (pressArray.contains(true) && PlayState.instance.generatedMusic)
-		{
-			Haptic.vibrate(100, 100);
-
-			PlayState.instance.currentStage.getBoyfriend().holdTimer = 0;
-
-			var possibleNotes:Array<Note> = []; // notes that can be hit
-			var directionList:Array<Int> = []; // directions that can be hit
-			var dumbNotes:Array<Note> = []; // notes to kill later
-
-			PlayState.instance.activeNotes.forEachAlive(function(daNote:Note)
-			{
-				if (daNote.canBeHit && daNote.mustPress && !daNote.tooLate && !daNote.wasGoodHit)
-				{
-					if (directionList.contains(daNote.data.noteData))
-					{
-						for (coolNote in possibleNotes)
-						{
-							if (coolNote.data.noteData == daNote.data.noteData
-								&& Math.abs(daNote.data.strumTime - coolNote.data.strumTime) < 10)
-							{ // if it's the same note twice at < 10ms distance, just delete it
-								// EXCEPT u cant delete it in this loop cuz it fucks with the collection lol
-								dumbNotes.push(daNote);
-								break;
-							}
-							else if (coolNote.data.noteData == daNote.data.noteData && daNote.data.strumTime < coolNote.data.strumTime)
-							{ // if daNote is earlier than existing note (coolNote), replace
-								possibleNotes.remove(coolNote);
-								possibleNotes.push(daNote);
-								break;
-							}
-						}
-					}
-					else
-					{
-						possibleNotes.push(daNote);
-						directionList.push(daNote.data.noteData);
-					}
-				}
-			});
-
-			for (note in dumbNotes)
-			{
-				FlxG.log.add("killing dumb ass note at " + note.data.strumTime);
-				note.kill();
-				PlayState.instance.activeNotes.remove(note, true);
-				note.destroy();
-			}
-
-			possibleNotes.sort((a, b) -> Std.int(a.data.strumTime - b.data.strumTime));
-
-			if (PlayState.instance.perfectMode)
-				PlayState.instance.goodNoteHit(possibleNotes[0]);
-			else if (possibleNotes.length > 0)
-			{
-				for (shit in 0...pressArray.length)
-				{ // if a direction is hit that shouldn't be
-					if (pressArray[shit] && !directionList.contains(shit))
-						PlayState.instance.ghostNoteMiss(shit);
-				}
-				for (coolNote in possibleNotes)
-				{
-					if (pressArray[coolNote.data.noteData])
-						PlayState.instance.goodNoteHit(coolNote);
-				}
-			}
-			else
-			{
-				// HNGGG I really want to add an option for ghost tapping
-				// L + ratio
-				for (shit in 0...pressArray.length)
-					if (pressArray[shit])
-						PlayState.instance.ghostNoteMiss(shit, false);
-			}
-		}
-
-		if (PlayState.instance == null || PlayState.instance.currentStage == null)
-			return;
-
-		for (keyId => isPressed in pressArray)
-		{
-			if (playerStrumline == null)
-				continue;
-			var arrow:StrumlineArrow = PlayState.instance.playerStrumline.getArrow(keyId);
-
-			if (isPressed && arrow.animation.curAnim.name != 'confirm')
-			{
-				arrow.playAnimation('pressed');
-			}
-			if (!holdArray[keyId])
-			{
-				arrow.playAnimation('static');
-			}
-		}
-	}
-
-	/**
-	 * Called when a player presses a key with no note present.
-	 * Scripts can modify the amount of health/score lost, whether player animations or sounds are used,
-	 * or even cancel the event entirely.
-	 * 
-	 * @param direction 
-	 * @param hasPossibleNotes 
-	 */
-	function ghostNoteMiss(direction:funkin.noteStuff.NoteBasic.NoteType = 1, hasPossibleNotes:Bool = true):Void
-	{
-		var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in.
-			hasPossibleNotes, // Whether there was a note you could have hit.
-			- 0.035 * 2, // How much health to add (negative).
-			- 10 // Amount of score to add (negative).
-		);
-		dispatchEvent(event);
-
-		// Calling event.cancelEvent() skips animations and penalties. Neat!
-		if (event.eventCanceled)
-			return;
-
-		health += event.healthChange;
-
-		if (!isPracticeMode)
-			songScore += event.scoreChange;
-
-		if (event.playSound)
-		{
-			vocals.volume = 0;
-			FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
-		}
-	}
-
-	function noteMiss(note:Note):Void
-	{
-		// a MISS is when you let a note scroll past you!!
-		Highscore.tallies.missed++;
-
-		var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, Highscore.tallies.combo, true);
-		dispatchEvent(event);
-		// Calling event.cancelEvent() skips all the other logic! Neat!
-		if (event.eventCanceled)
-			return;
-
-		health -= 0.0775;
-		if (!isPracticeMode)
-			songScore -= 10;
-		vocals.volume = 0;
-
-		if (Highscore.tallies.combo != 0)
-		{
-			Highscore.tallies.combo = comboPopUps.displayCombo(0);
-		}
-
-		note.active = false;
-		note.visible = false;
-
-		note.kill();
-		activeNotes.remove(note, true);
-		note.destroy();
-	}
-
-	function goodNoteHit(note:Note):Void
-	{
-		if (!note.wasGoodHit)
-		{
-			var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true);
-			dispatchEvent(event);
-
-			// Calling event.cancelEvent() skips all the other logic! Neat!
-			if (event.eventCanceled)
-				return;
-
-			if (!note.isSustainNote)
-			{
-				Highscore.tallies.combo++;
-				Highscore.tallies.totalNotesHit++;
-
-				if (Highscore.tallies.combo > Highscore.tallies.maxCombo)
-					Highscore.tallies.maxCombo = Highscore.tallies.combo;
-
-				popUpScore(note.data.strumTime, note);
-			}
-
-			playerStrumline.getArrow(note.data.noteData).playAnimation('confirm', true);
-
-			note.wasGoodHit = true;
-			vocals.volume = 1;
-
-			if (!note.isSustainNote)
-			{
-				note.kill();
-				activeNotes.remove(note, true);
-				note.destroy();
-			}
-		}
-	}
-
-	override function stepHit():Bool
-	{
-		if (SongLoad.songData == null)
-			return false;
-
-		// super.stepHit() returns false if a module cancelled the event.
-		if (!super.stepHit())
-			return false;
-
-		if (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 20
-			|| Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 20)
-		{
-			resyncVocals();
-		}
-
-		if (iconP1 != null)
-			iconP1.onStepHit(Std.int(Conductor.currentStep));
-		if (iconP2 != null)
-			iconP2.onStepHit(Std.int(Conductor.currentStep));
-
-		return true;
-	}
-
-	override function beatHit():Bool
-	{
-		// super.beatHit() returns false if a module cancelled the event.
-		if (!super.beatHit())
-			return false;
-
-		if (generatedMusic)
-		{
-			// TODO: Sort more efficiently, or less often, to improve performance.
-			activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING);
-		}
-
-		// Moving this code into the `beatHit` function allows for scripts and modules to control the camera better.
-		if (currentSong != null)
-		{
-			if (generatedMusic && SongLoad.getSong()[Std.int(Conductor.currentStep / 16)] != null)
-			{
-				// cameraRightSide = SongLoad.getSong()[Std.int(Conductor.currentStep / 16)].mustHitSection;
-			}
-
-			if (SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)] != null)
-			{
-				if (SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)].changeBPM)
-				{
-					Conductor.forceBPM(SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)].bpm);
-					FlxG.log.add('CHANGED BPM!');
-				}
-			}
-		}
-
-		// Manage the camera focus, if necessary.
-		// controlCamera();
-
-		// HARDCODING FOR MILF ZOOMS!
-
-		if (PreferencesMenu.getPref('camera-zoom'))
-		{
-			if (currentSong != null
-				&& currentSong.song.toLowerCase() == 'milf'
-				&& Conductor.currentBeat >= 168
-				&& Conductor.currentBeat < 200
-				&& camZooming
-				&& FlxG.camera.zoom < 1.35)
-			{
-				FlxG.camera.zoom += 0.015 * FlxCamera.defaultZoom;
-				camHUD.zoom += 0.03;
-			}
-
-			if (camZooming && FlxG.camera.zoom < (1.35 * FlxCamera.defaultZoom) && Conductor.currentBeat % 4 == 0)
-			{
-				FlxG.camera.zoom += 0.015 * FlxCamera.defaultZoom;
-				camHUD.zoom += 0.03;
-			}
-		}
-
-		// That combo counter that got spoiled that one time.
-		// Comes with NEAT visual and audio effects.
-
-		// bruh this var is bonkers i thot it was a function lmfaooo
-
-		// Break up into individual lines to aid debugging.
-
-		var shouldShowComboText:Bool = false;
-		if (currentSong != null)
-		{
-			shouldShowComboText = (Conductor.currentBeat % 8 == 7);
-			var daSection = SongLoad.getSong()[Std.int(Conductor.currentBeat / 16)];
-			shouldShowComboText = shouldShowComboText && (daSection != null && daSection.mustHitSection);
-			shouldShowComboText = shouldShowComboText && (Highscore.tallies.combo > 5);
-
-			var daNextSection = SongLoad.getSong()[Std.int(Conductor.currentBeat / 16) + 1];
-			var isEndOfSong = SongLoad.getSong().length < Std.int(Conductor.currentBeat / 16);
-			shouldShowComboText = shouldShowComboText && (isEndOfSong || (daNextSection != null && !daNextSection.mustHitSection));
-		}
-
-		if (shouldShowComboText)
-		{
-			var animShit:ComboCounter = new ComboCounter(-100, 300, Highscore.tallies.combo);
-			animShit.scrollFactor.set(0.6, 0.6);
-			animShit.cameras = [camHUD];
-			add(animShit);
-
-			var frameShit:Float = (1 / 24) * 2; // equals 2 frames in the animation
-
-			new FlxTimer().start(((Conductor.crochet / 1000) * 1.25) - frameShit, function(tmr)
-			{
-				animShit.forceFinish();
-			});
-		}
-
-		// Make the characters dance on the beat
-		danceOnBeat();
-
-		return true;
-	}
-
-	/**
-	 * Handles characters dancing to the beat of the current song.
-	 * 
-	 * TODO: Move some of this logic into `Bopper.hx`
-	 */
-	public function danceOnBeat()
-	{
-		if (currentStage == null)
-			return;
-
-		// TODO: Move this to a song event.
-		if (Conductor.currentBeat % 16 == 15 // && currentSong.song == 'Tutorial'
-			&& currentStage.getDad().characterId == 'gf'
-			&& Conductor.currentBeat > 16
-			&& Conductor.currentBeat < 48)
-		{
-			currentStage.getBoyfriend().playAnimation('hey', true);
-			currentStage.getDad().playAnimation('cheer', true);
-		}
-	}
-
-	/**
-	 * Constructs the strumlines for each player.
-	 */
-	function buildStrumlines():Void
-	{
-		var strumlineStyle:StrumlineStyle = NORMAL;
-
-		// TODO: Put this in the chart or something?
-		switch (currentStageId)
-		{
-			case 'school':
-				strumlineStyle = PIXEL;
-			case 'schoolEvil':
-				strumlineStyle = PIXEL;
-		}
-
-		var strumlineYPos = Strumline.getYPos();
-
-		playerStrumline = new Strumline(0, strumlineStyle, 4);
-		playerStrumline.x = 50 + FlxG.width / 2;
-		playerStrumline.y = strumlineYPos;
-		// Set the z-index so they don't appear in front of notes.
-		playerStrumline.zIndex = 100;
-		add(playerStrumline);
-		playerStrumline.cameras = [camHUD];
-
-		if (!isStoryMode)
-		{
-			playerStrumline.fadeInArrows();
-		}
-
-		enemyStrumline = new Strumline(1, strumlineStyle, 4);
-		enemyStrumline.x = 50;
-		enemyStrumline.y = strumlineYPos;
-		// Set the z-index so they don't appear in front of notes.
-		enemyStrumline.zIndex = 100;
-		add(enemyStrumline);
-		enemyStrumline.cameras = [camHUD];
-
-		if (!isStoryMode)
-		{
-			enemyStrumline.fadeInArrows();
-		}
-
-		this.refresh();
-	}
-
-	/**
-	 * Function called before opening a new substate.
-	 * @param subState The substate to open.
-	 */
-	override function openSubState(subState:FlxSubState)
-	{
-		// If there is a substate which requires the game to continue,
-		// then make this a condition.
-		var shouldPause = true;
-
-		if (shouldPause)
-		{
-			// Pause the music.
-			if (FlxG.sound.music != null)
-			{
-				FlxG.sound.music.pause();
-				if (vocals != null)
-					vocals.pause();
-			}
-
-			// Pause the countdown.
-			Countdown.pauseCountdown();
-		}
-
-		super.openSubState(subState);
-	}
-
-	/**
-	 * Function called before closing the current substate.
-	 * @param subState 
-	 */
-	override function closeSubState()
-	{
-		if (isGamePaused)
-		{
-			var event:ScriptEvent = new ScriptEvent(ScriptEvent.RESUME, true);
-
-			dispatchEvent(event);
-
-			if (event.eventCanceled)
-				return;
-
-			if (FlxG.sound.music != null && !startingSong && !isInCutscene)
-				resyncVocals();
-
-			// Resume the countdown.
-			Countdown.resumeCountdown();
-
-			#if discord_rpc
-			if (startTimer.finished)
-				DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true,
-					songLength - Conductor.songPosition);
-			else
-				DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
-			#end
-		}
-
-		super.closeSubState();
-	}
-
-	/**
-	 * Prepares to start the countdown.
-	 * Ends any running cutscenes, creates the strumlines, and starts the countdown.
-	 */
-	function startCountdown():Void
-	{
-		var result = Countdown.performCountdown(currentStageId.startsWith('school'));
-		if (!result)
-			return;
-
-		isInCutscene = false;
-		camHUD.visible = true;
-		talking = false;
-
-		buildStrumlines();
-	}
-
-	override function dispatchEvent(event:ScriptEvent):Void
-	{
-		// ORDER: Module, Stage, Character, Song, Note
-		// Modules should get the first chance to cancel the event.
-
-		// super.dispatchEvent(event) dispatches event to module scripts.
-		super.dispatchEvent(event);
-
-		// Dispatch event to stage script.
-		ScriptEventDispatcher.callEvent(currentStage, event);
-
-		// Dispatch event to character script(s).
-		if (currentStage != null)
-			currentStage.dispatchToCharacters(event);
-
-		// TODO: Dispatch event to song script
-	}
-
-	/**
-	 * Updates the position and contents of the score display.
-	 */
-	function updateScoreText():Void
-	{
-		// TODO: Add functionality for modules to update the score text.
-		scoreText.text = "Score:" + songScore;
-	}
-
-	/**
-	 * Updates the values of the health bar.
-	 */
-	function updateHealthBar():Void
-	{
-		healthLerp = FlxMath.lerp(healthLerp, health, 0.15);
-	}
-
-	/**
-	 * Resets the camera's zoom level and focus point.
-	 */
-	public function resetCamera():Void
-	{
-		FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.04);
-		FlxG.camera.targetOffset.set();
-		FlxG.camera.zoom = defaultCameraZoom;
-		FlxG.camera.focusOn(cameraFollowPoint.getPosition());
-	}
-
-	/**
-	 * Perform necessary cleanup before leaving the PlayState.
-	 */
-	function performCleanup()
-	{
-		// Uncache the song.
-		if (currentChart != null)
-		{
-		}
-		else if (currentSong != null)
-		{
-			openfl.utils.Assets.cache.clear(Paths.inst(currentSong.song));
-			openfl.utils.Assets.cache.clear(Paths.voices(currentSong.song));
-		}
-
-		// Remove reference to stage and remove sprites from it to save memory.
-		if (currentStage != null)
-		{
-			remove(currentStage);
-			currentStage.kill();
-			dispatchEvent(new ScriptEvent(ScriptEvent.DESTROY, false));
-			currentStage = null;
-		}
-
-		GameOverSubstate.reset();
-
-		// Clear the static reference to this state.
-		instance = null;
-	}
-
-	/**
-	 * This function is called whenever Flixel switches switching to a new FlxState.
-	 * @return Whether to actually switch to the new state.
-	 */
-	override function switchTo(nextState:FlxState):Bool
-	{
-		var result = super.switchTo(nextState);
-
-		if (result)
-		{
-			performCleanup();
-		}
-
-		return result;
-	}
+  /**
+   * STATIC VARIABLES
+   * Static variables should be used for information that must be persisted between states or between resets,
+   * such as the active song or song playlist.
+   */
+  /**
+   * The currently active PlayState.
+   * Since there is only one PlayState in existance at a time, we can use a singleton.
+   */
+  public static var instance:PlayState = null;
+
+  /**
+   * The currently active song. Includes data about what stage should be used, what characters,
+   * and the notes to be played.
+   */
+  public static var currentSong:SwagSong = null;
+
+  public static var currentSong_NEW:Song = null;
+
+  /**
+   * Whether the game is currently in Story Mode. If false, we are in Free Play Mode.
+   */
+  public static var isStoryMode:Bool = false;
+
+  /**
+   * Whether the game is currently in Practice Mode.
+   * If true, player will not lose gain or lose score from notes.
+   */
+  public static var isPracticeMode:Bool = false;
+
+  /**
+   * Whether the game is currently in a cutscene, and gameplay should be stopped.
+   */
+  public static var isInCutscene:Bool = false;
+
+  /**
+   * Whether the game is currently in the countdown before the song resumes.
+   */
+  public static var isInCountdown:Bool = false;
+
+  /**
+   * Gets set to true when the PlayState needs to reset (player opted to restart or died).
+   * Gets disabled once resetting happens.
+   */
+  public static var needsReset:Bool = false;
+
+  /**
+   * The current "Blueball Counter" to display in the pause menu.
+   * Resets when you beat a song or go back to the main menu.
+   */
+  public static var deathCounter:Int = 0;
+
+  /**
+   * The default camera zoom level. The camera lerps back to this after zooming in.
+   * Defaults to 1.05 but may be larger or smaller depending on the current stage.
+   */
+  public static var defaultCameraZoom:Float = 1.05;
+
+  /**
+   * Used to persist the position of the `cameraFollowPosition` between resets.
+   */
+  private static var previousCameraFollowPoint:FlxObject = null;
+
+  /**
+   * PUBLIC INSTANCE VARIABLES
+   * Public instance variables should be used for information that must be reset or dereferenced
+   * every time the state is reset, such as the currently active stage, but may need to be accessed externally.
+   */
+  /**
+   * The currently active Stage. This is the object containing all the props.
+   */
+  public var currentStage:Stage = null;
+
+  public var currentChart(get, null):SongDifficulty;
+
+  /**
+   * The internal ID of the currently active Stage.
+   * Used to retrieve the data required to build the `currentStage`.
+   */
+  public var currentStageId:String = '';
+
+  /**
+   * The player's current health.
+   * The default maximum health is 2.0, and the default starting health is 1.0.
+   */
+  public var health:Float = 1;
+
+  /**
+   * The player's current score.
+   */
+  public var songScore:Int = 0;
+
+  /**
+   * An empty FlxObject contained in the scene.
+   * The current gameplay camera will be centered on this object. Tween its position to move the camera smoothly.
+   * 
+   * This is an FlxSprite for two reasons:
+   * 1. It needs to be an object in the scene for the camera to be configured to follow it.
+   * 2. It needs to be an FlxSprite to allow a graphic (optionally, for debug purposes) to be drawn on it.
+   */
+  public var cameraFollowPoint:FlxSprite = new FlxSprite(0, 0);
+
+  /**
+   * PRIVATE INSTANCE VARIABLES
+   * Private instance variables should be used for information that must be reset or dereferenced
+   * every time the state is reset, but should not be accessed externally.
+   */
+  /**
+   * The Array containing the notes that are not currently on the screen.
+   * The `update()` function regularly shifts these out to add new notes to the screen.
+   */
+  private var inactiveNotes:Array<Note>;
+
+  private var songEvents:Array<SongEventData>;
+
+  /**
+   * If true, the player is allowed to pause the game.
+   * Disabled during the ending of a song.
+   */
+  private var mayPauseGame:Bool = true;
+
+  /**
+   * The displayed value of the player's health.
+   * Used to provide smooth animations based on linear interpolation of the player's health.
+   */
+  private var healthLerp:Float = 1;
+
+  /**
+   * Forcibly disables all update logic while the game moves back to the Menu state.
+   * This is used only when a critical error occurs and the game cannot continue.
+   */
+  private var criticalFailure:Bool = false;
+
+  /**
+   * RENDER OBJECTS
+   */
+  /**
+   * The SpriteGroup containing the notes that are currently on the screen or are about to be on the screen.
+   */
+  private var activeNotes:FlxTypedGroup<Note> = null;
+
+  /**
+   * The FlxText which displays the current score.
+   */
+  private var scoreText:FlxText;
+
+  /**
+   * The bar which displays the player's health.
+   * Dynamically updated based on the value of `healthLerp` (which is based on `health`).
+   */
+  public var healthBar:FlxBar;
+
+  /**
+   * The background image used for the health bar.
+   * Emma says the image is slightly skewed so I'm leaving it as an image instead of a `createGraphic`.
+   */
+  public var healthBarBG:FlxSprite;
+
+  /**
+   * The health icon representing the player.
+   */
+  public var iconP1:HealthIcon;
+
+  /**
+   * The health icon representing the opponent.
+   */
+  public var iconP2:HealthIcon;
+
+  /**
+   * The sprite group containing active player's strumline notes.
+   */
+  public var playerStrumline:Strumline;
+
+  /**
+   * The sprite group containing opponent's strumline notes.
+   */
+  public var enemyStrumline:Strumline;
+
+  /**
+   * The camera which contains, and controls visibility of, the user interface elements.
+   */
+  public var camHUD:FlxCamera;
+
+  /**
+   * The camera which contains, and controls visibility of, the stage and characters.
+   */
+  public var camGame:FlxCamera;
+
+  /**
+   * PROPERTIES
+   */
+  /**
+   * If a substate is rendering over the PlayState, it is paused and normal update logic is skipped.
+   * Examples include:
+   * - The Pause screen is open.
+   * - The Game Over screen is open.
+   * - The Chart Editor screen is open.
+   */
+  private var isGamePaused(get, never):Bool;
+
+  function get_isGamePaused():Bool
+  {
+    // Note: If there is a substate which requires the game to act unpaused,
+    //       this should be changed to include something like `&& Std.isOfType()`
+    return this.subState != null;
+  }
+
+  // TODO: Reorganize these variables (maybe there should be a separate class like Conductor just to hold them?)
+  public static var storyWeek:Int = 0;
+  public static var storyPlaylist:Array<String> = [];
+  public static var storyDifficulty:Int = 1;
+  public static var storyDifficulty_NEW:String = "normal";
+  public static var seenCutscene:Bool = false;
+  public static var campaignScore:Int = 0;
+
+  private var vocals:VoicesGroup;
+  private var vocalsFinished:Bool = false;
+
+  private var camZooming:Bool = false;
+  private var gfSpeed:Int = 1;
+  // private var combo:Int = 0;
+  private var generatedMusic:Bool = false;
+  private var startingSong:Bool = false;
+
+  var dialogue:Array<String>;
+  var talking:Bool = true;
+  var doof:DialogueBox;
+  var grpNoteSplashes:FlxTypedGroup<NoteSplash>;
+  var comboPopUps:PopUpStuff;
+  var perfectMode:Bool = false;
+  var previousFrameTime:Int = 0;
+  var songTime:Float = 0;
+
+  #if discord_rpc
+  // Discord RPC variables
+  var storyDifficultyText:String = "";
+  var iconRPC:String = "";
+  var songLength:Float = 0;
+  var detailsText:String = "";
+  var detailsPausedText:String = "";
+  #end
+
+  override public function create()
+  {
+    super.create();
+
+    if (currentSong == null && currentSong_NEW == null)
+    {
+      criticalFailure = true;
+
+      lime.app.Application.current.window.alert("There was a critical error while accessing the selected song. Click OK to return to the main menu.",
+        "Error loading PlayState");
+      FlxG.switchState(new MainMenuState());
+      return;
+    }
+
+    instance = this;
+
+    if (currentSong_NEW != null)
+    {
+      // TODO: Do this in the loading state.
+      currentSong_NEW.cacheCharts(true);
+    }
+
+    // Displays the camera follow point as a sprite for debug purposes.
+    // TODO: Put this on a toggle?
+    cameraFollowPoint.makeGraphic(8, 8, 0xFF00FF00);
+    cameraFollowPoint.visible = false;
+    cameraFollowPoint.zIndex = 1000000;
+
+    // Reduce physics accuracy (who cares!!!) to improve animation quality.
+    FlxG.fixedTimestep = false;
+
+    // This state receives update() even when a substate is active.
+    this.persistentUpdate = true;
+    // This state receives draw calls even when a substate is active.
+    this.persistentDraw = true;
+
+    // Stop any pre-existing music.
+    if (FlxG.sound.music != null)
+      FlxG.sound.music.stop();
+
+    // Prepare the current song to be played.
+    if (currentChart != null)
+    {
+      currentChart.cacheInst();
+      currentChart.cacheVocals();
+    }
+    else
+    {
+      FlxG.sound.cache(Paths.inst(currentSong.song));
+      FlxG.sound.cache(Paths.voices(currentSong.song));
+    }
+
+    // Initialize stage stuff.
+    initCameras();
+
+    if (currentSong == null && currentSong_NEW == null)
+    {
+      currentSong = SongLoad.loadFromJson('tutorial');
+    }
+
+    if (currentSong_NEW != null)
+    {
+      Conductor.mapTimeChanges(currentChart.timeChanges);
+      // Conductor.bpm = currentChart.getStartingBPM();
+
+      // TODO: Support for dialog.
+    }
+    else
+    {
+      Conductor.mapBPMChanges(currentSong);
+      // Conductor.bpm = currentSong.bpm;
+
+      switch (currentSong.song.toLowerCase())
+      {
+        case 'senpai':
+          dialogue = CoolUtil.coolTextFile(Paths.txt('songs/senpai/senpaiDialogue'));
+        case 'roses':
+          dialogue = CoolUtil.coolTextFile(Paths.txt('songs/roses/rosesDialogue'));
+        case 'thorns':
+          dialogue = CoolUtil.coolTextFile(Paths.txt('songs/thorns/thornsDialogue'));
+      }
+    }
+
+    Conductor.update(-5000);
+
+    if (dialogue != null)
+    {
+      doof = new DialogueBox(false, dialogue);
+      doof.scrollFactor.set();
+      doof.finishThing = startCountdown;
+      doof.cameras = [camHUD];
+    }
+
+    // Once the song is loaded, we can continue and initialize the stage.
+
+    var healthBarYPos:Float = PreferencesMenu.getPref('downscroll') ? FlxG.height * 0.1 : FlxG.height * 0.9;
+    healthBarBG = new FlxSprite(0, healthBarYPos).loadGraphic(Paths.image('healthBar'));
+    healthBarBG.screenCenter(X);
+    healthBarBG.scrollFactor.set(0, 0);
+    add(healthBarBG);
+
+    healthBar = new FlxBar(healthBarBG.x + 4, healthBarBG.y + 4, RIGHT_TO_LEFT, Std.int(healthBarBG.width - 8), Std.int(healthBarBG.height - 8), this,
+      'healthLerp', 0, 2);
+    healthBar.scrollFactor.set();
+    healthBar.createFilledBar(Constants.COLOR_HEALTH_BAR_RED, Constants.COLOR_HEALTH_BAR_GREEN);
+    add(healthBar);
+
+    initStage();
+    initCharacters();
+    #if discord_rpc
+    initDiscord();
+    #end
+
+    // Configure camera follow point.
+    if (previousCameraFollowPoint != null)
+    {
+      cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y);
+      previousCameraFollowPoint = null;
+    }
+    add(cameraFollowPoint);
+
+    comboPopUps = new PopUpStuff();
+    comboPopUps.cameras = [camHUD];
+    add(comboPopUps);
+
+    grpNoteSplashes = new FlxTypedGroup<NoteSplash>();
+
+    var noteSplash:NoteSplash = new NoteSplash(100, 100, 0);
+    grpNoteSplashes.add(noteSplash);
+    noteSplash.alpha = 0.1;
+
+    add(grpNoteSplashes);
+
+    if (currentSong_NEW != null)
+    {
+      generateSong_NEW();
+    }
+    else
+    {
+      generateSong();
+    }
+
+    resetCamera();
+
+    FlxG.worldBounds.set(0, 0, FlxG.width, FlxG.height);
+
+    scoreText = new FlxText(healthBarBG.x + healthBarBG.width - 190, healthBarBG.y + 30, 0, "", 20);
+    scoreText.setFormat(Paths.font("vcr.ttf"), 16, FlxColor.WHITE, RIGHT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK);
+    scoreText.scrollFactor.set();
+    add(scoreText);
+
+    // Attach the groups to the HUD camera so they are rendered independent of the stage.
+    grpNoteSplashes.cameras = [camHUD];
+    activeNotes.cameras = [camHUD];
+    healthBar.cameras = [camHUD];
+    healthBarBG.cameras = [camHUD];
+    iconP1.cameras = [camHUD];
+    iconP2.cameras = [camHUD];
+    scoreText.cameras = [camHUD];
+    leftWatermarkText.cameras = [camHUD];
+    rightWatermarkText.cameras = [camHUD];
+
+    // if (SONG.song == 'South')
+    // FlxG.camera.alpha = 0.7;
+    // UI_camera.zoom = 1;
+
+    // cameras = [FlxG.cameras.list[1]];
+    startingSong = true;
+
+    if (isStoryMode && !seenCutscene)
+    {
+      seenCutscene = true;
+
+      switch (currentSong.song.toLowerCase())
+      {
+        case "winter-horrorland":
+          VanillaCutscenes.playHorrorStartCutscene();
+        case 'senpai' | 'roses' | 'thorns':
+          schoolIntro(doof); // doof is assumed to be non-null, lol!
+        case 'ugh':
+          VanillaCutscenes.playUghCutscene();
+        case 'stress':
+          VanillaCutscenes.playStressCutscene();
+        case 'guns':
+          VanillaCutscenes.playGunsCutscene();
+        default:
+          // VanillaCutscenes will call startCountdown later.
+          // TODO: Alternatively: make a song script that allows startCountdown to be called,
+          // then cancels the countdown, hides the strumline, plays the cutscene,
+          // then calls Countdown.performCountdown()
+          startCountdown();
+      }
+    }
+    else
+    {
+      startCountdown();
+    }
+
+    #if debug
+    this.rightWatermarkText.text = Constants.VERSION;
+    #end
+  }
+
+  function get_currentChart():SongDifficulty
+  {
+    if (currentSong_NEW == null || storyDifficulty_NEW == null)
+      return null;
+    return currentSong_NEW.getDifficulty(storyDifficulty_NEW);
+  }
+
+  /**
+   * Initializes the game and HUD cameras.
+   */
+  function initCameras()
+  {
+    // Configure the default camera zoom level.
+    defaultCameraZoom = FlxCamera.defaultZoom * 1.05;
+
+    camGame = new SwagCamera();
+    camHUD = new FlxCamera();
+    camHUD.bgColor.alpha = 0;
+
+    FlxG.cameras.reset(camGame);
+    FlxG.cameras.add(camHUD, false);
+  }
+
+  function initStage()
+  {
+    if (currentSong_NEW != null)
+    {
+      initStage_NEW();
+      return;
+    }
+
+    // TODO: Move stageId to the song file.
+    switch (currentSong.song.toLowerCase())
+    {
+      case 'spookeez' | 'monster' | 'south':
+        currentStageId = "spookyMansion";
+      case 'pico' | 'blammed' | 'philly':
+        currentStageId = 'phillyTrain';
+      case "milf" | 'satin-panties' | 'high':
+        currentStageId = 'limoRide';
+      case "cocoa" | 'eggnog':
+        currentStageId = 'mallXmas';
+      case 'winter-horrorland':
+        currentStageId = 'mallEvil';
+      case 'senpai' | 'roses':
+        currentStageId = 'school';
+      case "darnell" | "lit-up" | "2hot":
+        currentStageId = 'phillyStreets';
+      // currentStageId = 'pyro';
+      case "blazin":
+        currentStageId = 'phillyBlazin';
+      // currentStageId = 'pyro';
+      case 'pyro':
+        currentStageId = 'pyro';
+      case 'thorns':
+        currentStageId = 'schoolEvil';
+      case 'guns' | 'stress' | 'ugh':
+        currentStageId = 'tankmanBattlefield';
+      default:
+        currentStageId = "mainStage";
+    }
+    // Loads the relevant stage based on its ID.
+    loadStage(currentStageId);
+  }
+
+  function initStage_NEW()
+  {
+    if (currentChart == null)
+    {
+      trace('Song difficulty could not be loaded.');
+    }
+
+    if (currentChart.stage != null && currentChart.stage != '')
+    {
+      currentStageId = currentChart.stage;
+    }
+    else
+    {
+      currentStageId = SongValidator.DEFAULT_STAGE;
+    }
+
+    loadStage(currentStageId);
+  }
+
+  function initCharacters()
+  {
+    if (currentSong_NEW != null)
+    {
+      initCharacters_NEW();
+      return;
+    }
+
+    iconP1 = new HealthIcon(currentSong.player1, 0);
+    iconP1.y = healthBar.y - (iconP1.height / 2);
+    add(iconP1);
+
+    iconP2 = new HealthIcon(currentSong.player2, 1);
+    iconP2.y = healthBar.y - (iconP2.height / 2);
+    add(iconP2);
+
+    //
+    // GIRLFRIEND
+    //
+
+    // TODO: Tie the GF version to the song data, not the stage ID or the current player.
+    var gfVersion:String = 'gf';
+
+    switch (currentStageId)
+    {
+      case 'pyro' | 'phillyStreets':
+        gfVersion = 'nene';
+      case 'blazin':
+        gfVersion = '';
+      case 'limoRide':
+        gfVersion = 'gf-car';
+      case 'mallXmas' | 'mallEvil':
+        gfVersion = 'gf-christmas';
+      case 'school' | 'schoolEvil':
+        gfVersion = 'gf-pixel';
+      case 'tankmanBattlefield':
+        gfVersion = 'gf-tankmen';
+    }
+
+    if (currentSong.player1 == "pico")
+      gfVersion = "nene";
+
+    if (currentSong.song.toLowerCase() == 'stress')
+      gfVersion = 'pico-speaker';
+
+    if (currentSong.song.toLowerCase() == 'tutorial')
+      gfVersion = '';
+
+    //
+    // GIRLFRIEND
+    //
+    var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(gfVersion);
+
+    if (girlfriend != null)
+    {
+      girlfriend.characterType = CharacterType.GF;
+      girlfriend.scrollFactor.set(0.95, 0.95);
+      if (gfVersion == 'pico-speaker')
+      {
+        girlfriend.x -= 50;
+        girlfriend.y -= 200;
+      }
+    }
+    else if (gfVersion != '')
+    {
+      trace('WARNING: Could not load girlfriend character with ID ${gfVersion}, skipping...');
+    }
+
+    //
+    // DAD
+    //
+    var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player2);
+
+    if (dad != null)
+    {
+      dad.characterType = CharacterType.DAD;
+    }
+
+    switch (currentSong.player2)
+    {
+      case 'gf':
+        if (isStoryMode)
+        {
+          cameraFollowPoint.x += 600;
+          tweenCamIn();
+        }
+    }
+
+    //
+    // BOYFRIEND
+    //
+    var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player1);
+
+    if (boyfriend != null)
+    {
+      boyfriend.characterType = CharacterType.BF;
+    }
+
+    if (currentStage != null)
+    {
+      // We're using Eric's stage handler.
+      // Characters get added to the stage, not the main scene.
+      if (girlfriend != null)
+      {
+        currentStage.addCharacter(girlfriend, GF);
+      }
+
+      if (boyfriend != null)
+      {
+        currentStage.addCharacter(boyfriend, BF);
+      }
+
+      if (dad != null)
+      {
+        currentStage.addCharacter(dad, DAD);
+        // Camera starts at dad.
+        cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y);
+      }
+
+      // Redo z-indexes.
+      currentStage.refresh();
+    }
+  }
+
+  function initCharacters_NEW()
+  {
+    if (currentSong_NEW == null || currentChart == null)
+    {
+      trace('Song difficulty could not be loaded.');
+    }
+
+    // TODO: Switch playable character by manipulating this value.
+    // TODO: How to choose which one to use for story mode?
+
+    var playableChars = currentChart.getPlayableChars();
+    var currentPlayer = 'bf';
+
+    if (playableChars.length == 0)
+    {
+      trace('WARNING: No playable characters found for this song.');
+    }
+    else if (playableChars.indexOf(currentPlayer) == -1)
+    {
+      currentPlayer = playableChars[0];
+    }
+
+    var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayer);
+
+    //
+    // GIRLFRIEND
+    //
+    var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.girlfriend);
+
+    if (girlfriend != null)
+    {
+      girlfriend.characterType = CharacterType.GF;
+    }
+    else if (currentCharData.girlfriend != '')
+    {
+      trace('WARNING: Could not load girlfriend character with ID ${currentCharData.girlfriend}, skipping...');
+    }
+    else
+    {
+      // Chosen GF was '' so we don't load one.
+    }
+
+    //
+    // DAD
+    //
+    var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.opponent);
+
+    if (dad != null)
+    {
+      dad.characterType = CharacterType.DAD;
+    }
+
+    // TODO: Cut out this code/make it generic.
+    switch (currentCharData.opponent)
+    {
+      case 'gf':
+        if (isStoryMode)
+        {
+          cameraFollowPoint.x += 600;
+          tweenCamIn();
+        }
+    }
+
+    //
+    // OPPONENT HEALTH ICON
+    //
+    iconP2 = new HealthIcon(currentCharData.opponent, 1);
+    iconP2.y = healthBar.y - (iconP2.height / 2);
+    add(iconP2);
+
+    //
+    // BOYFRIEND
+    //
+    var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayer);
+
+    if (boyfriend != null)
+    {
+      boyfriend.characterType = CharacterType.BF;
+    }
+
+    //
+    // PLAYER HEALTH ICON
+    //
+    iconP1 = new HealthIcon(currentPlayer, 0);
+    iconP1.y = healthBar.y - (iconP1.height / 2);
+    add(iconP1);
+
+    //
+    // ADD CHARACTERS TO SCENE
+    //
+
+    if (currentStage != null)
+    {
+      // Characters get added to the stage, not the main scene.
+      if (girlfriend != null)
+      {
+        currentStage.addCharacter(girlfriend, GF);
+      }
+
+      if (boyfriend != null)
+      {
+        currentStage.addCharacter(boyfriend, BF);
+      }
+
+      if (dad != null)
+      {
+        currentStage.addCharacter(dad, DAD);
+        // Camera starts at dad.
+        cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y);
+      }
+
+      // Rearrange by z-indexes.
+      currentStage.refresh();
+    }
+  }
+
+  /**
+   * Removes any references to the current stage, then clears the stage cache,
+   * then reloads all the stages.
+   * 
+   * This is useful for when you want to edit a stage without reloading the whole game.
+   * Reloading works on both the JSON and the HXC, if applicable.
+   * 
+   * Call this by pressing F5 on a debug build.
+   */
+  override function debug_refreshModules()
+  {
+    // Remove the current stage. If the stage gets deleted while it's still in use,
+    // it'll probably crash the game or something.
+    if (this.currentStage != null)
+    {
+      remove(currentStage);
+      var event:ScriptEvent = new ScriptEvent(ScriptEvent.DESTROY, false);
+      ScriptEventDispatcher.callEvent(currentStage, event);
+      currentStage = null;
+    }
+
+    super.debug_refreshModules();
+  }
+
+  /**
+   * Pauses music and vocals easily.
+   */
+  public function pauseMusic()
+  {
+    FlxG.sound.music.pause();
+    vocals.pause();
+  }
+
+  /**
+   * Loads stage data from cache, assembles the props,
+   * and adds it to the state.
+   * @param id 
+   */
+  function loadStage(id:String)
+  {
+    currentStage = StageDataParser.fetchStage(id);
+
+    if (currentStage != null)
+    {
+      // Actually create and position the sprites.
+      var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
+      ScriptEventDispatcher.callEvent(currentStage, event);
+
+      // Apply camera zoom.
+      defaultCameraZoom = currentStage.camZoom;
+
+      // Add the stage to the scene.
+      this.add(currentStage);
+    }
+  }
+
+  function initDiscord():Void
+  {
+    #if discord_rpc
+    storyDifficultyText = difficultyString();
+    iconRPC = currentSong.player2;
+
+    // To avoid having duplicate images in Discord assets
+    switch (iconRPC)
+    {
+      case 'senpai-angry':
+        iconRPC = 'senpai';
+      case 'monster-christmas':
+        iconRPC = 'monster';
+      case 'mom-car':
+        iconRPC = 'mom';
+    }
+
+    // String that contains the mode defined here so it isn't necessary to call changePresence for each mode
+    detailsText = isStoryMode ? "Story Mode: Week " + storyWeek : "Freeplay";
+    detailsPausedText = "Paused - " + detailsText;
+
+    // Updating Discord Rich Presence.
+    DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
+    #end
+  }
+
+  function schoolIntro(?dialogueBox:DialogueBox):Void
+  {
+    var black:FlxSprite = new FlxSprite(-100, -100).makeGraphic(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
+    black.scrollFactor.set();
+    add(black);
+
+    var red:FlxSprite = new FlxSprite(-100, -100).makeGraphic(FlxG.width * 2, FlxG.height * 2, 0xFFff1b31);
+    red.scrollFactor.set();
+
+    var senpaiEvil:FlxSprite = new FlxSprite();
+    senpaiEvil.frames = Paths.getSparrowAtlas('weeb/senpaiCrazy');
+    senpaiEvil.animation.addByPrefix('idle', 'Senpai Pre Explosion', 24, false);
+    senpaiEvil.setGraphicSize(Std.int(senpaiEvil.width * Constants.PIXEL_ART_SCALE));
+    senpaiEvil.scrollFactor.set();
+    senpaiEvil.updateHitbox();
+    senpaiEvil.screenCenter();
+    senpaiEvil.x += senpaiEvil.width / 5;
+
+    if (currentSong.song.toLowerCase() == 'roses' || currentSong.song.toLowerCase() == 'thorns')
+    {
+      remove(black);
+
+      if (currentSong.song.toLowerCase() == 'thorns')
+      {
+        add(red);
+        camHUD.visible = false;
+      }
+      else
+        FlxG.sound.play(Paths.sound('ANGRY'));
+      // moved senpai angry noise in here to clean up cutscene switch case lol
+    }
+
+    new FlxTimer().start(0.3, function(tmr:FlxTimer)
+    {
+      black.alpha -= 0.15;
+
+      if (black.alpha > 0)
+        tmr.reset(0.3);
+      else
+      {
+        if (dialogueBox != null)
+        {
+          isInCutscene = true;
+
+          if (currentSong.song.toLowerCase() == 'thorns')
+          {
+            add(senpaiEvil);
+            senpaiEvil.alpha = 0;
+            new FlxTimer().start(0.3, function(swagTimer:FlxTimer)
+            {
+              senpaiEvil.alpha += 0.15;
+              if (senpaiEvil.alpha < 1)
+                swagTimer.reset();
+              else
+              {
+                senpaiEvil.animation.play('idle');
+                FlxG.sound.play(Paths.sound('Senpai_Dies'), 1, false, null, true, function()
+                {
+                  remove(senpaiEvil);
+                  remove(red);
+                  FlxG.camera.fade(FlxColor.WHITE, 0.01, true, function()
+                  {
+                    add(dialogueBox);
+                    camHUD.visible = true;
+                  }, true);
+                });
+                new FlxTimer().start(3.2, function(deadTime:FlxTimer)
+                {
+                  FlxG.camera.fade(FlxColor.WHITE, 1.6, false);
+                });
+              }
+            });
+          }
+          else
+            add(dialogueBox);
+        }
+        else
+          startCountdown();
+
+        remove(black);
+      }
+    });
+  }
+
+  function startSong():Void
+  {
+    dispatchEvent(new ScriptEvent(ScriptEvent.SONG_START));
+
+    startingSong = false;
+
+    previousFrameTime = FlxG.game.ticks;
+
+    if (!isGamePaused)
+    {
+      // if (FlxG.sound.music != null)
+      // FlxG.sound.music.play(true);
+      // else
+      if (currentChart != null)
+      {
+        currentChart.playInst(1.0, false);
+      }
+      else
+      {
+        FlxG.sound.playMusic(Paths.inst(currentSong.song), 1, false);
+      }
+    }
+
+    FlxG.sound.music.onComplete = endSong;
+    trace('Playing vocals...');
+    vocals.play();
+
+    #if discord_rpc
+    // Song duration in a float, useful for the time left feature
+    songLength = FlxG.sound.music.length;
+
+    // Updating Discord Rich Presence (with Time Left)
+    DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, songLength);
+    #end
+  }
+
+  private function generateSong():Void
+  {
+    // FlxG.log.add(ChartParser.parse());
+
+    Conductor.forceBPM(currentSong.bpm);
+
+    currentSong.song = currentSong.song;
+
+    if (currentSong.needsVoices)
+      vocals = VoicesGroup.build(currentSong.song, currentSong.voiceList);
+    else
+      vocals = VoicesGroup.build(currentSong.song, null);
+
+    vocals.members[0].onComplete = function()
+    {
+      vocalsFinished = true;
+    };
+
+    trace(vocals);
+
+    activeNotes = new FlxTypedGroup<Note>();
+    activeNotes.zIndex = 1000;
+    add(activeNotes);
+
+    regenNoteData();
+
+    generatedMusic = true;
+  }
+
+  private function generateSong_NEW():Void
+  {
+    if (currentChart == null)
+    {
+      trace('Song difficulty could not be loaded.');
+    }
+
+    Conductor.forceBPM(currentChart.getStartingBPM());
+
+    // TODO: Fix grouped vocals
+    vocals = currentChart.buildVocals();
+    vocals.members[0].onComplete = function()
+    {
+      vocalsFinished = true;
+    }
+
+    // Create the rendered note group.
+    activeNotes = new FlxTypedGroup<Note>();
+    activeNotes.zIndex = 1000;
+    add(activeNotes);
+
+    regenNoteData_NEW();
+
+    generatedMusic = true;
+  }
+
+  function regenNoteData():Void
+  {
+    // resets combo, should prob put somewhere else!
+    Highscore.tallies.combo = 0;
+    Highscore.tallies = new Tallies();
+    // make unspawn notes shit def empty
+    inactiveNotes = [];
+
+    activeNotes.forEach(function(nt)
+    {
+      nt.followsTime = false;
+      FlxTween.tween(nt, {y: FlxG.height + nt.y}, 0.5, {
+        ease: FlxEase.expoIn,
+        onComplete: function(twn)
+        {
+          nt.kill();
+          activeNotes.remove(nt, true);
+          nt.destroy();
+        }
+      });
+    });
+
+    var noteData:Array<SwagSection>;
+
+    // NEW SHIT
+    noteData = SongLoad.getSong();
+
+    for (section in noteData)
+    {
+      for (songNotes in section.sectionNotes)
+      {
+        var daStrumTime:Float = songNotes.strumTime;
+        // TODO: Replace 4 with strumlineSize
+        var daNoteData:Int = Std.int(songNotes.noteData % 4);
+        var gottaHitNote:Bool = section.mustHitSection;
+
+        if (songNotes.highStakes) // noteData > 3
+          gottaHitNote = !section.mustHitSection;
+
+        var oldNote:Note;
+        if (inactiveNotes.length > 0)
+          oldNote = inactiveNotes[Std.int(inactiveNotes.length - 1)];
+        else
+          oldNote = null;
+
+        var strumlineStyle:StrumlineStyle = NORMAL;
+
+        // TODO: Put this in the chart or something?
+        switch (currentStageId)
+        {
+          case 'school':
+            strumlineStyle = PIXEL;
+          case 'schoolEvil':
+            strumlineStyle = PIXEL;
+        }
+
+        var swagNote:Note = new Note(daStrumTime, daNoteData, oldNote, false, strumlineStyle);
+        // swagNote.data = songNotes;
+        swagNote.data.sustainLength = songNotes.sustainLength;
+        swagNote.data.noteKind = songNotes.noteKind;
+        swagNote.scrollFactor.set(0, 0);
+
+        var susLength:Float = swagNote.data.sustainLength;
+
+        susLength = susLength / Conductor.stepCrochet;
+        inactiveNotes.push(swagNote);
+
+        for (susNote in 0...Math.round(susLength))
+        {
+          oldNote = inactiveNotes[Std.int(inactiveNotes.length - 1)];
+
+          var sustainNote:Note = new Note(daStrumTime + (Conductor.stepCrochet * susNote) + Conductor.stepCrochet, daNoteData, oldNote, true, strumlineStyle);
+          sustainNote.data.noteKind = songNotes.noteKind;
+          sustainNote.scrollFactor.set();
+          inactiveNotes.push(sustainNote);
+
+          sustainNote.mustPress = gottaHitNote;
+
+          if (sustainNote.mustPress)
+            sustainNote.x += FlxG.width / 2; // general offset
+        }
+
+        // TODO: Replace 4 with strumlineSize
+        swagNote.mustPress = gottaHitNote;
+
+        if (swagNote.mustPress)
+        {
+          if (playerStrumline != null)
+          {
+            swagNote.x = playerStrumline.getArrow(swagNote.data.noteData).x;
+          }
+          else
+          {
+            swagNote.x += FlxG.width / 2; // general offset
+          }
+        }
+        else
+        {
+          if (enemyStrumline != null)
+          {
+            swagNote.x = enemyStrumline.getArrow(swagNote.data.noteData).x;
+          }
+          else
+          {
+            // swagNote.x += FlxG.width / 2; // general offset
+          }
+        }
+      }
+    }
+
+    inactiveNotes.sort(function(a:Note, b:Note):Int
+    {
+      return SortUtil.byStrumtime(FlxSort.ASCENDING, a, b);
+    });
+  }
+
+  function regenNoteData_NEW():Void
+  {
+    Highscore.tallies.combo = 0;
+    Highscore.tallies = new Tallies();
+
+    // Reset song events.
+    songEvents = currentChart.getEvents();
+    SongEventParser.resetEvents(songEvents);
+
+    // Destroy inactive notes.
+    inactiveNotes = [];
+
+    // Destroy active notes.
+    activeNotes.forEach(function(nt)
+    {
+      nt.followsTime = false;
+      FlxTween.tween(nt, {y: FlxG.height + nt.y}, 0.5, {
+        ease: FlxEase.expoIn,
+        onComplete: function(twn)
+        {
+          nt.kill();
+          activeNotes.remove(nt, true);
+          nt.destroy();
+        }
+      });
+    });
+
+    var noteData:Array<SongNoteData> = currentChart.notes;
+
+    var oldNote:Note = null;
+    for (songNote in noteData)
+    {
+      var mustHitNote:Bool = songNote.getMustHitNote();
+
+      // TODO: Put this in the chart or something?
+      var strumlineStyle:StrumlineStyle = null;
+      switch (currentStageId)
+      {
+        case 'school':
+          strumlineStyle = PIXEL;
+        case 'schoolEvil':
+          strumlineStyle = PIXEL;
+        default:
+          strumlineStyle = NORMAL;
+      }
+
+      var newNote:Note = new Note(songNote.time, songNote.data, oldNote, false, strumlineStyle);
+      newNote.mustPress = mustHitNote;
+      newNote.data.sustainLength = songNote.length;
+      newNote.data.noteKind = songNote.kind;
+      newNote.scrollFactor.set(0, 0);
+
+      // Note positioning.
+      // TODO: Make this more robust.
+      if (newNote.mustPress)
+      {
+        if (playerStrumline != null)
+        {
+          // Align with the strumline arrow.
+          newNote.x = playerStrumline.getArrow(songNote.getDirection()).x;
+        }
+        else
+        {
+          // Assume strumline position.
+          newNote.x += FlxG.width / 2;
+        }
+      }
+      else
+      {
+        if (enemyStrumline != null)
+        {
+          newNote.x = enemyStrumline.getArrow(songNote.getDirection()).x;
+        }
+        else
+        {
+          // newNote.x += 0;
+        }
+      }
+
+      inactiveNotes.push(newNote);
+
+      oldNote = newNote;
+
+      // Generate X sustain notes.
+      var sustainSections = Math.round(songNote.length / Conductor.stepCrochet);
+      for (noteIndex in 0...sustainSections)
+      {
+        var noteTimeOffset:Float = Conductor.stepCrochet + (Conductor.stepCrochet * noteIndex);
+        var sustainNote:Note = new Note(songNote.time + noteTimeOffset, songNote.data, oldNote, true, strumlineStyle);
+        sustainNote.mustPress = mustHitNote;
+        sustainNote.data.noteKind = songNote.kind;
+        sustainNote.scrollFactor.set(0, 0);
+
+        if (sustainNote.mustPress)
+        {
+          if (playerStrumline != null)
+          {
+            // Align with the strumline arrow.
+            sustainNote.x = playerStrumline.getArrow(songNote.getDirection()).x;
+          }
+          else
+          {
+            // Assume strumline position.
+            sustainNote.x += FlxG.width / 2;
+          }
+        }
+        else
+        {
+          if (enemyStrumline != null)
+          {
+            sustainNote.x = enemyStrumline.getArrow(songNote.getDirection()).x;
+          }
+          else
+          {
+            // newNote.x += 0;
+          }
+        }
+
+        inactiveNotes.push(sustainNote);
+
+        oldNote = sustainNote;
+      }
+    }
+
+    // Sorting is an expensive operation.
+    // Assume it was done in the chart file.
+    /**
+      inactiveNotes.sort(function(a:Note, b:Note):Int
+      {
+        return SortUtil.byStrumtime(FlxSort.ASCENDING, a, b);
+      });
+    **/
+  }
+
+  function tweenCamIn():Void
+  {
+    FlxTween.tween(FlxG.camera, {zoom: 1.3 * FlxCamera.defaultZoom}, (Conductor.stepCrochet * 4 / 1000), {ease: FlxEase.elasticInOut});
+  }
+
+  #if discord_rpc
+  override public function onFocus():Void
+  {
+    if (health > 0 && !paused && FlxG.autoPause)
+    {
+      if (Conductor.songPosition > 0.0)
+        DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, songLength - Conductor.songPosition);
+      else
+        DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
+    }
+
+    super.onFocus();
+  }
+
+  override public function onFocusLost():Void
+  {
+    if (health > 0 && !paused && FlxG.autoPause)
+      DiscordClient.changePresence(detailsPausedText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
+
+    super.onFocusLost();
+  }
+  #end
+
+  function resyncVocals():Void
+  {
+    if (_exiting || vocals == null)
+      return;
+
+    vocals.pause();
+
+    FlxG.sound.music.play();
+    Conductor.update(FlxG.sound.music.time + Conductor.offset);
+
+    if (vocalsFinished)
+      return;
+
+    vocals.time = FlxG.sound.music.time;
+    vocals.play();
+  }
+
+  override public function update(elapsed:Float)
+  {
+    super.update(elapsed);
+
+    if (criticalFailure)
+      return;
+
+    if (FlxG.keys.justPressed.U)
+    {
+      // hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!!
+      persistentUpdate = false;
+      openSubState(new StageOffsetSubstate());
+    }
+
+    updateHealthBar();
+    updateScoreText();
+
+    if (needsReset)
+    {
+      dispatchEvent(new ScriptEvent(ScriptEvent.SONG_RETRY));
+
+      resetCamera();
+
+      persistentUpdate = true;
+      persistentDraw = true;
+
+      startingSong = true;
+
+      FlxG.sound.music.pause();
+      vocals.pause();
+
+      FlxG.sound.music.time = 0;
+
+      currentStage.resetStage();
+
+      // Delete all notes and reset the arrays.
+      if (currentChart != null)
+      {
+        regenNoteData_NEW();
+      }
+      else
+      {
+        regenNoteData();
+      }
+
+      health = 1;
+      songScore = 0;
+      Highscore.tallies.combo = 0;
+      Countdown.performCountdown(currentStageId.startsWith('school'));
+
+      needsReset = false;
+    }
+
+    #if !debug
+    perfectMode = false;
+    #else
+    if (FlxG.keys.justPressed.H)
+      camHUD.visible = !camHUD.visible;
+    #end
+
+    // do this BEFORE super.update() so songPosition is accurate
+    if (startingSong)
+    {
+      if (isInCountdown)
+      {
+        Conductor.songPosition += elapsed * 1000;
+        if (Conductor.songPosition >= 0)
+          startSong();
+      }
+    }
+    else
+    {
+      if (Paths.SOUND_EXT == 'mp3')
+        Conductor.offset = -13; // DO NOT FORGET TO REMOVE THE HARDCODE! WHEN I MAKE BETTER OFFSET SYSTEM!
+
+      Conductor.update(FlxG.sound.music.time + Conductor.offset);
+
+      if (!isGamePaused)
+      {
+        songTime += FlxG.game.ticks - previousFrameTime;
+        previousFrameTime = FlxG.game.ticks;
+
+        // Interpolation type beat
+        if (Conductor.lastSongPos != Conductor.songPosition)
+        {
+          songTime = (songTime + Conductor.songPosition) / 2;
+          Conductor.lastSongPos = Conductor.songPosition;
+        }
+      }
+    }
+
+    var androidPause:Bool = false;
+
+    #if android
+    androidPause = FlxG.android.justPressed.BACK;
+    #end
+
+    if ((controls.PAUSE || androidPause) && isInCountdown && mayPauseGame)
+    {
+      var event = new PauseScriptEvent(FlxG.random.bool(1 / 1000));
+
+      dispatchEvent(event);
+
+      if (!event.eventCanceled)
+      {
+        // Pause updates while the substate is open, preventing the game state from advancing.
+        persistentUpdate = false;
+        // Enable drawing while the substate is open, allowing the game state to be shown behind the pause menu.
+        persistentDraw = true;
+
+        // There is a 1/1000 change to use a special pause menu.
+        // This prevents the player from resuming, but that's the point.
+        // It's a reference to Gitaroo Man, which doesn't let you pause the game.
+        if (event.gitaroo)
+        {
+          FlxG.switchState(new GitarooPause());
+        }
+        else
+        {
+          var boyfriendPos = currentStage.getBoyfriend().getScreenPosition();
+          var pauseSubState = new PauseSubState(boyfriendPos.x, boyfriendPos.y);
+          openSubState(pauseSubState);
+          pauseSubState.camera = camHUD;
+          boyfriendPos.put();
+        }
+
+        #if discord_rpc
+        DiscordClient.changePresence(detailsPausedText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
+        #end
+      }
+    }
+
+    #if debug
+    // 1: End the song immediately.
+    if (FlxG.keys.justPressed.ONE)
+      endSong();
+
+    // 2: Gain 10% health.
+    if (FlxG.keys.justPressed.TWO)
+      health += 0.1 * 2.0;
+
+    // 3: Lose 5% health.
+    if (FlxG.keys.justPressed.THREE)
+      health -= 0.05 * 2.0;
+    #end
+
+    // 7: Move to the charter.
+    if (FlxG.keys.justPressed.SEVEN)
+    {
+      FlxG.switchState(new ChartingState());
+
+      #if discord_rpc
+      DiscordClient.changePresence("Chart Editor", null, null, true);
+      #end
+    }
+
+    // 8: Move to the offset editor.
+    if (FlxG.keys.justPressed.EIGHT)
+      FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
+
+    // 9: Toggle the old icon.
+    if (FlxG.keys.justPressed.NINE)
+      iconP1.toggleOldIcon();
+
+    #if debug
+    // PAGEUP: Skip forward one section.
+    // SHIFT+PAGEUP: Skip forward ten sections.
+    if (FlxG.keys.justPressed.PAGEUP)
+      changeSection(FlxG.keys.pressed.SHIFT ? 10 : 1);
+    // PAGEDOWN: Skip backward one section. Doesn't replace notes.
+    // SHIFT+PAGEDOWN: Skip backward ten sections.
+    if (FlxG.keys.justPressed.PAGEDOWN)
+      changeSection(FlxG.keys.pressed.SHIFT ? -10 : -1);
+    #end
+
+    if (health > 2.0)
+      health = 2.0;
+    if (health < 0.0)
+      health = 0.0;
+
+    if (camZooming && subState == null)
+    {
+      FlxG.camera.zoom = FlxMath.lerp(defaultCameraZoom, FlxG.camera.zoom, 0.95);
+      camHUD.zoom = FlxMath.lerp(1 * FlxCamera.defaultZoom, camHUD.zoom, 0.95);
+    }
+
+    FlxG.watch.addQuick("beatShit", Conductor.currentBeat);
+    FlxG.watch.addQuick("stepShit", Conductor.currentStep);
+    if (currentStage != null)
+    {
+      FlxG.watch.addQuick("bfAnim", currentStage.getBoyfriend().getCurrentAnimation());
+    }
+    FlxG.watch.addQuick("songPos", Conductor.songPosition);
+
+    if (currentSong != null && currentSong.song == 'Fresh')
+    {
+      switch (Conductor.currentBeat)
+      {
+        case 16:
+          camZooming = true;
+          gfSpeed = 2;
+        case 48:
+          gfSpeed = 1;
+        case 80:
+          gfSpeed = 2;
+        case 112:
+          gfSpeed = 1;
+      }
+    }
+
+    if (!isInCutscene && !_exiting)
+    {
+      // RESET = Quick Game Over Screen
+      if (controls.RESET)
+      {
+        health = 0;
+        trace("RESET = True");
+      }
+
+      #if CAN_CHEAT // brandon's a pussy
+      if (controls.CHEAT)
+      {
+        health += 1;
+        trace("User is cheating!");
+      }
+      #end
+
+      if (health <= 0 && !isPracticeMode)
+      {
+        vocals.pause();
+        FlxG.sound.music.pause();
+
+        deathCounter += 1;
+
+        dispatchEvent(new ScriptEvent(ScriptEvent.GAME_OVER));
+
+        // Disable updates, preventing animations in the background from playing.
+        persistentUpdate = false;
+        #if debug
+        if (FlxG.keys.pressed.THREE)
+        {
+          // TODO: Change the key or delete this?
+          // In debug builds, pressing 3 to kill the player makes the background transparent.
+          persistentDraw = true;
+        }
+        else
+        {
+        #end
+          persistentDraw = false;
+        #if debug
+        }
+        #end
+
+        var gameOverSubstate = new GameOverSubstate();
+        openSubState(gameOverSubstate);
+
+        #if discord_rpc
+        // Game Over doesn't get his own variable because it's only used here
+        DiscordClient.changePresence("Game Over - " + detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
+        #end
+      }
+    }
+
+    while (inactiveNotes[0] != null && inactiveNotes[0].data.strumTime - Conductor.songPosition < 1800 / SongLoad.getSpeed())
+    {
+      var dunceNote:Note = inactiveNotes[0];
+
+      if (dunceNote.mustPress && !dunceNote.isSustainNote)
+        Highscore.tallies.totalNotes++;
+
+      activeNotes.add(dunceNote);
+
+      inactiveNotes.shift();
+    }
+
+    if (generatedMusic && playerStrumline != null)
+    {
+      activeNotes.forEachAlive(function(daNote:Note)
+      {
+        if ((PreferencesMenu.getPref('downscroll') && daNote.y < -daNote.height)
+          || (!PreferencesMenu.getPref('downscroll') && daNote.y > FlxG.height))
+        {
+          daNote.active = false;
+          daNote.visible = false;
+        }
+        else
+        {
+          daNote.visible = true;
+          daNote.active = true;
+        }
+
+        var strumLineMid = playerStrumline.y + Note.swagWidth / 2;
+
+        if (daNote.followsTime)
+          daNote.y = (Conductor.songPosition - daNote.data.strumTime) * (0.45 * FlxMath.roundDecimal(SongLoad.getSpeed(), 2) * daNote.noteSpeedMulti);
+
+        if (PreferencesMenu.getPref('downscroll'))
+        {
+          daNote.y += playerStrumline.y;
+          if (daNote.isSustainNote)
+          {
+            if (daNote.animation.curAnim.name.endsWith("end") && daNote.prevNote != null)
+              daNote.y += daNote.prevNote.height;
+            else
+              daNote.y += daNote.height / 2;
+
+            if ((!daNote.mustPress || (daNote.wasGoodHit || (daNote.prevNote.wasGoodHit && !daNote.canBeHit)))
+              && daNote.y - daNote.offset.y * daNote.scale.y + daNote.height >= strumLineMid)
+            {
+              applyClipRect(daNote);
+            }
+          }
+        }
+        else
+        {
+          if (daNote.followsTime)
+            daNote.y = playerStrumline.y - daNote.y;
+          if (daNote.isSustainNote
+            && (!daNote.mustPress || (daNote.wasGoodHit || (daNote.prevNote.wasGoodHit && !daNote.canBeHit)))
+            && daNote.y + daNote.offset.y * daNote.scale.y <= strumLineMid)
+          {
+            applyClipRect(daNote);
+          }
+        }
+
+        if (!daNote.mustPress && daNote.wasGoodHit && !daNote.tooLate)
+        {
+          if (currentSong != null && currentSong.song != 'Tutorial')
+            camZooming = true;
+
+          var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, Highscore.tallies.combo, true);
+          dispatchEvent(event);
+
+          // Calling event.cancelEvent() in a module should force the CPU to miss the note.
+          // This is useful for cool shit, including but not limited to:
+          // - Making the AI ignore notes which are hazardous.
+          // - Making the AI miss notes on purpose for aesthetic reasons.
+          if (event.eventCanceled)
+          {
+            daNote.tooLate = true;
+          }
+          else
+          {
+            // Volume of DAD.
+            if (currentSong != null && currentSong.needsVoices)
+              vocals.volume = 1;
+          }
+        }
+
+        // WIP interpolation shit? Need to fix the pause issue
+        // daNote.y = (strumLine.y - (songTime - daNote.strumTime) * (0.45 * SONG.speed[SongLoad.curDiff]));
+
+        // removing this so whether the note misses or not is entirely up to Note class
+        // var noteMiss:Bool = daNote.y < -daNote.height;
+
+        // if (PreferencesMenu.getPref('downscroll'))
+        // noteMiss = daNote.y > FlxG.height;
+
+        if (daNote.isSustainNote && daNote.wasGoodHit)
+        {
+          if ((!PreferencesMenu.getPref('downscroll') && daNote.y < -daNote.height)
+            || (PreferencesMenu.getPref('downscroll') && daNote.y > FlxG.height))
+          {
+            daNote.active = false;
+            daNote.visible = false;
+
+            daNote.kill();
+            activeNotes.remove(daNote, true);
+            daNote.destroy();
+          }
+        }
+        if (daNote.wasGoodHit)
+        {
+          daNote.active = false;
+          daNote.visible = false;
+
+          daNote.kill();
+          activeNotes.remove(daNote, true);
+          daNote.destroy();
+        }
+
+        if (daNote.tooLate)
+        {
+          noteMiss(daNote);
+        }
+      });
+    }
+
+    if (songEvents != null && songEvents.length > 0)
+    {
+      var songEventsToActivate:Array<SongEventData> = SongEventParser.queryEvents(songEvents, Conductor.songPosition);
+
+      if (songEventsToActivate.length > 0)
+      {
+        trace('Found ${songEventsToActivate.length} event(s) to activate.');
+        for (event in songEventsToActivate)
+        {
+          SongEventParser.handleEvent(event);
+        }
+      }
+    }
+
+    if (!isInCutscene)
+      keyShit(true);
+  }
+
+  function applyClipRect(daNote:Note):Void
+  {
+    // clipRect is applied to graphic itself so use frame Heights
+    var swagRect:FlxRect = new FlxRect(0, 0, daNote.frameWidth, daNote.frameHeight);
+    var strumLineMid = playerStrumline.y + Note.swagWidth / 2;
+
+    if (PreferencesMenu.getPref('downscroll'))
+    {
+      swagRect.height = (strumLineMid - daNote.y) / daNote.scale.y;
+      swagRect.y = daNote.frameHeight - swagRect.height;
+    }
+    else
+    {
+      swagRect.y = (strumLineMid - daNote.y) / daNote.scale.y;
+      swagRect.height -= swagRect.y;
+    }
+
+    daNote.clipRect = swagRect;
+  }
+
+  function killCombo():Void
+  {
+    // Girlfriend gets sad if you combo break after hitting 5 notes.
+    if (currentStage != null && currentStage.getGirlfriend() != null)
+      if (Highscore.tallies.combo > 5 && currentStage.getGirlfriend().hasAnimation('sad'))
+        currentStage.getGirlfriend().playAnimation('sad');
+
+    if (Highscore.tallies.combo != 0)
+    {
+      Highscore.tallies.combo = comboPopUps.displayCombo(0);
+    }
+  }
+
+  #if debug
+  /**
+   * Jumps forward or backward a number of sections in the song.
+   * Accounts for BPM changes, does not prevent death from skipped notes.
+   * @param sec 
+   */
+  function changeSection(sec:Int):Void
+  {
+    FlxG.sound.music.pause();
+
+    var daBPM:Float = currentSong.bpm;
+    var daPos:Float = 0;
+    for (i in 0...(Std.int(Conductor.currentStep / 16 + sec)))
+    {
+      var section = SongLoad.getSong()[i];
+      if (section == null)
+        continue;
+      if (section.changeBPM)
+      {
+        daBPM = SongLoad.getSong()[i].bpm;
+      }
+      daPos += 4 * (1000 * 60 / daBPM);
+    }
+    Conductor.songPosition = FlxG.sound.music.time = daPos;
+    Conductor.songPosition += Conductor.offset;
+    resyncVocals();
+  }
+  #end
+
+  function endSong():Void
+  {
+    dispatchEvent(new ScriptEvent(ScriptEvent.SONG_END));
+
+    seenCutscene = false;
+    deathCounter = 0;
+    mayPauseGame = false;
+    FlxG.sound.music.volume = 0;
+    vocals.volume = 0;
+    if (currentSong != null && currentSong.validScore)
+    {
+      // crackhead double thingie, sets whether was new highscore, AND saves the song!
+      Highscore.tallies.isNewHighscore = Highscore.saveScore(currentSong.song, songScore, storyDifficulty);
+
+      Highscore.saveCompletion(currentSong.song, Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, storyDifficulty);
+    }
+
+    if (isStoryMode)
+    {
+      campaignScore += songScore;
+
+      storyPlaylist.remove(storyPlaylist[0]);
+
+      if (storyPlaylist.length <= 0)
+      {
+        FlxG.sound.playMusic(Paths.music('freakyMenu'));
+
+        transIn = FlxTransitionableState.defaultTransIn;
+        transOut = FlxTransitionableState.defaultTransOut;
+
+        switch (storyWeek)
+        {
+          case 7:
+            FlxG.switchState(new VideoState());
+          default:
+            FlxG.switchState(new StoryMenuState());
+        }
+
+        // if ()
+        StoryMenuState.weekUnlocked[Std.int(Math.min(storyWeek + 1, StoryMenuState.weekUnlocked.length - 1))] = true;
+
+        if (currentSong.validScore)
+        {
+          NGio.unlockMedal(60961);
+          Highscore.saveWeekScore(storyWeek, campaignScore, storyDifficulty);
+        }
+
+        FlxG.save.data.weekUnlocked = StoryMenuState.weekUnlocked;
+        FlxG.save.flush();
+      }
+      else
+      {
+        var difficulty:String = "";
+
+        if (storyDifficulty == 0)
+          difficulty = '-easy';
+
+        if (storyDifficulty == 2)
+          difficulty = '-hard';
+
+        trace('LOADING NEXT SONG');
+        trace(storyPlaylist[0].toLowerCase() + difficulty);
+
+        FlxTransitionableState.skipNextTransIn = true;
+        FlxTransitionableState.skipNextTransOut = true;
+
+        FlxG.sound.music.stop();
+        vocals.stop();
+
+        if (currentSong.song.toLowerCase() == 'eggnog')
+        {
+          var blackShit:FlxSprite = new FlxSprite(-FlxG.width * FlxG.camera.zoom,
+            -FlxG.height * FlxG.camera.zoom).makeGraphic(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK);
+          blackShit.scrollFactor.set();
+          add(blackShit);
+          camHUD.visible = false;
+          isInCutscene = true;
+
+          FlxG.sound.play(Paths.sound('Lights_Shut_off'), function()
+          {
+            // no camFollow so it centers on horror tree
+            currentSong = SongLoad.loadFromJson(storyPlaylist[0].toLowerCase() + difficulty, storyPlaylist[0]);
+            LoadingState.loadAndSwitchState(new PlayState());
+          });
+        }
+        else
+        {
+          previousCameraFollowPoint = cameraFollowPoint;
+
+          currentSong = SongLoad.loadFromJson(storyPlaylist[0].toLowerCase() + difficulty, storyPlaylist[0]);
+          LoadingState.loadAndSwitchState(new PlayState());
+        }
+      }
+    }
+    else
+    {
+      trace('WENT TO RESULTS SCREEN!');
+      // unloadAssets();
+
+      camZooming = false;
+
+      FlxG.camera.follow(PlayState.instance.currentStage.getGirlfriend(), null, 0.05);
+      FlxG.camera.targetOffset.y -= 350;
+      FlxG.camera.targetOffset.x += 20;
+
+      FlxTween.tween(camHUD, {alpha: 0}, 0.6);
+
+      new FlxTimer().start(0.8, _ ->
+      {
+        currentStage.getGirlfriend().animation.play("cheer");
+
+        FlxTween.tween(FlxG.camera, {zoom: 1200}, 1.1, {
+          ease: FlxEase.expoIn,
+          onComplete: _ ->
+          {
+            persistentUpdate = false;
+            vocals.stop();
+            camHUD.alpha = 1;
+            var res:ResultState = new ResultState();
+            res.camera = camHUD;
+            openSubState(res);
+          }
+        });
+      });
+      // FlxG.switchState(new FreeplayState());
+    }
+  }
+
+  // gives score and pops up rating
+  private function popUpScore(strumtime:Float, daNote:Note):Void
+  {
+    var noteDiff:Float = Math.abs(strumtime - Conductor.songPosition);
+    // boyfriend.playAnimation('hey');
+    vocals.volume = 1;
+
+    var isSick:Bool = false;
+    var score = Scoring.scoreNote(noteDiff, PBOT1);
+    var daRating = Scoring.judgeNote(noteDiff, PBOT1);
+    var healthMulti:Float = daNote.lowStakes ? 0.002 : 0.033;
+
+    if (noteDiff > Note.HIT_WINDOW * Note.BAD_THRESHOLD)
+    {
+      healthMulti *= 0; // no health on shit note
+      daRating = 'shit';
+      Highscore.tallies.shit += 1;
+      score = 50;
+    }
+    else if (noteDiff > Note.HIT_WINDOW * Note.GOOD_THRESHOLD)
+    {
+      healthMulti *= 0.2;
+      daRating = 'bad';
+      Highscore.tallies.bad += 1;
+    }
+    else if (noteDiff > Note.HIT_WINDOW * Note.SICK_THRESHOLD)
+    {
+      healthMulti *= 0.78;
+      daRating = 'good';
+      Highscore.tallies.good += 1;
+      score = 200;
+    }
+    else
+    {
+      isSick = true;
+    }
+
+    health += healthMulti;
+    if (isSick)
+    {
+      Highscore.tallies.sick += 1;
+      var noteSplash:NoteSplash = grpNoteSplashes.recycle(NoteSplash);
+      noteSplash.setupNoteSplash(daNote.x, daNote.y, daNote.data.noteData);
+      // new NoteSplash(daNote.x, daNote.y, daNote.noteData);
+      grpNoteSplashes.add(noteSplash);
+    }
+    // Only add the score if you're not on practice mode
+    if (!isPracticeMode)
+      songScore += score;
+    comboPopUps.displayRating(daRating);
+    if (Highscore.tallies.combo >= 10 || Highscore.tallies.combo == 0)
+      comboPopUps.displayCombo(Highscore.tallies.combo);
+  }
+
+  /*
+    function controlCamera()
+    {
+      if (currentStage == null)
+        return;
+
+      switch (cameraFocusCharacter)
+      {
+        default: // null = No change
+          break;
+        case 0: // Boyfriend
+          var isFocusedOnBF = cameraFollowPoint.x == currentStage.getBoyfriend().cameraFocusPoint.x;
+          if (!isFocusedOnBF)
+          {
+            // Focus the camera on the player.
+            cameraFollowPoint.setPosition(currentStage.getBoyfriend().cameraFocusPoint.x, currentStage.getBoyfriend().cameraFocusPoint.y);
+          }
+        case 1: // Dad
+          var isFocusedOnDad = cameraFollowPoint.x == currentStage.getDad().cameraFocusPoint.x;
+          if (!isFocusedOnDad)
+          {
+            cameraFollowPoint.setPosition(currentStage.getDad().cameraFocusPoint.x, currentStage.getDad().cameraFocusPoint.y);
+          }
+        case 2: // Girlfriend
+          var isFocusedOnGF = cameraFollowPoint.x == currentStage.getGirlfriend().cameraFocusPoint.x;
+          if (!isFocusedOnGF)
+          {
+            cameraFollowPoint.setPosition(currentStage.getGirlfriend().cameraFocusPoint.x, currentStage.getGirlfriend().cameraFocusPoint.y);
+          }
+      }
+
+      /*
+        if (cameraRightSide && !isFocusedOnBF)
+        {
+          // Focus the camera on the player.
+          cameraFollowPoint.setPosition(currentStage.getBoyfriend().cameraFocusPoint.x, currentStage.getBoyfriend().cameraFocusPoint.y);
+
+          // TODO: Un-hardcode this.
+          if (currentSong.song.toLowerCase() == 'tutorial')
+            FlxTween.tween(FlxG.camera, {zoom: 1 * FlxCamera.defaultZoom}, (Conductor.stepCrochet * 4 / 1000), {ease: FlxEase.elasticInOut});
+        }
+        else if (!cameraRightSide && !isFocusedOnDad)
+        {
+          // Focus the camera on the opponent.
+          cameraFollowPoint.setPosition(currentStage.getDad().cameraFocusPoint.x, currentStage.getDad().cameraFocusPoint.y);
+
+          // TODO: Un-hardcode this stuff.
+          if (currentStage.getDad().characterId == 'mom')
+          {
+            vocals.volume = 1;
+          }
+
+          if (currentSong.song.toLowerCase() == 'tutorial')
+            tweenCamIn();
+        }
+   */
+  // }
+
+  public function keyShit(test:Bool):Void
+  {
+    if (PlayState.instance == null)
+      return;
+
+    // control arrays, order L D R U
+    var holdArray:Array<Bool> = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT];
+    var pressArray:Array<Bool> = [
+      controls.NOTE_LEFT_P,
+      controls.NOTE_DOWN_P,
+      controls.NOTE_UP_P,
+      controls.NOTE_RIGHT_P
+    ];
+    var releaseArray:Array<Bool> = [
+      controls.NOTE_LEFT_R,
+      controls.NOTE_DOWN_R,
+      controls.NOTE_UP_R,
+      controls.NOTE_RIGHT_R
+    ];
+    // HOLDS, check for sustain notes
+    if (holdArray.contains(true) && PlayState.instance.generatedMusic)
+    {
+      PlayState.instance.activeNotes.forEachAlive(function(daNote:Note)
+      {
+        if (daNote.isSustainNote && daNote.canBeHit && daNote.mustPress && holdArray[daNote.data.noteData])
+          PlayState.instance.goodNoteHit(daNote);
+      });
+    }
+
+    // PRESSES, check for note hits
+    if (pressArray.contains(true) && PlayState.instance.generatedMusic)
+    {
+      Haptic.vibrate(100, 100);
+
+      PlayState.instance.currentStage.getBoyfriend().holdTimer = 0;
+
+      var possibleNotes:Array<Note> = []; // notes that can be hit
+      var directionList:Array<Int> = []; // directions that can be hit
+      var dumbNotes:Array<Note> = []; // notes to kill later
+
+      PlayState.instance.activeNotes.forEachAlive(function(daNote:Note)
+      {
+        if (daNote.canBeHit && daNote.mustPress && !daNote.tooLate && !daNote.wasGoodHit)
+        {
+          if (directionList.contains(daNote.data.noteData))
+          {
+            for (coolNote in possibleNotes)
+            {
+              if (coolNote.data.noteData == daNote.data.noteData && Math.abs(daNote.data.strumTime - coolNote.data.strumTime) < 10)
+              { // if it's the same note twice at < 10ms distance, just delete it
+                // EXCEPT u cant delete it in this loop cuz it fucks with the collection lol
+                dumbNotes.push(daNote);
+                break;
+              }
+              else if (coolNote.data.noteData == daNote.data.noteData && daNote.data.strumTime < coolNote.data.strumTime)
+              { // if daNote is earlier than existing note (coolNote), replace
+                possibleNotes.remove(coolNote);
+                possibleNotes.push(daNote);
+                break;
+              }
+            }
+          }
+          else
+          {
+            possibleNotes.push(daNote);
+            directionList.push(daNote.data.noteData);
+          }
+        }
+      });
+
+      for (note in dumbNotes)
+      {
+        FlxG.log.add("killing dumb ass note at " + note.data.strumTime);
+        note.kill();
+        PlayState.instance.activeNotes.remove(note, true);
+        note.destroy();
+      }
+
+      possibleNotes.sort((a, b) -> Std.int(a.data.strumTime - b.data.strumTime));
+
+      if (PlayState.instance.perfectMode)
+        PlayState.instance.goodNoteHit(possibleNotes[0]);
+      else if (possibleNotes.length > 0)
+      {
+        for (shit in 0...pressArray.length)
+        { // if a direction is hit that shouldn't be
+          if (pressArray[shit] && !directionList.contains(shit))
+            PlayState.instance.ghostNoteMiss(shit);
+        }
+        for (coolNote in possibleNotes)
+        {
+          if (pressArray[coolNote.data.noteData])
+            PlayState.instance.goodNoteHit(coolNote);
+        }
+      }
+      else
+      {
+        // HNGGG I really want to add an option for ghost tapping
+        // L + ratio
+        for (shit in 0...pressArray.length)
+          if (pressArray[shit])
+            PlayState.instance.ghostNoteMiss(shit, false);
+      }
+    }
+
+    if (PlayState.instance == null || PlayState.instance.currentStage == null)
+      return;
+
+    for (keyId => isPressed in pressArray)
+    {
+      if (playerStrumline == null)
+        continue;
+      var arrow:StrumlineArrow = PlayState.instance.playerStrumline.getArrow(keyId);
+
+      if (isPressed && arrow.animation.curAnim.name != 'confirm')
+      {
+        arrow.playAnimation('pressed');
+      }
+      if (!holdArray[keyId])
+      {
+        arrow.playAnimation('static');
+      }
+    }
+  }
+
+  /**
+   * Called when a player presses a key with no note present.
+   * Scripts can modify the amount of health/score lost, whether player animations or sounds are used,
+   * or even cancel the event entirely.
+   * 
+   * @param direction 
+   * @param hasPossibleNotes 
+   */
+  function ghostNoteMiss(direction:funkin.noteStuff.NoteBasic.NoteType = 1, hasPossibleNotes:Bool = true):Void
+  {
+    var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in.
+      hasPossibleNotes, // Whether there was a note you could have hit.
+      - 0.035 * 2, // How much health to add (negative).
+      - 10 // Amount of score to add (negative).
+    );
+    dispatchEvent(event);
+
+    // Calling event.cancelEvent() skips animations and penalties. Neat!
+    if (event.eventCanceled)
+      return;
+
+    health += event.healthChange;
+
+    if (!isPracticeMode)
+      songScore += event.scoreChange;
+
+    if (event.playSound)
+    {
+      vocals.volume = 0;
+      FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
+    }
+  }
+
+  function noteMiss(note:Note):Void
+  {
+    // a MISS is when you let a note scroll past you!!
+    Highscore.tallies.missed++;
+
+    var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, Highscore.tallies.combo, true);
+    dispatchEvent(event);
+    // Calling event.cancelEvent() skips all the other logic! Neat!
+    if (event.eventCanceled)
+      return;
+
+    health -= 0.0775;
+    if (!isPracticeMode)
+      songScore -= 10;
+    vocals.volume = 0;
+
+    if (Highscore.tallies.combo != 0)
+    {
+      Highscore.tallies.combo = comboPopUps.displayCombo(0);
+    }
+
+    note.active = false;
+    note.visible = false;
+
+    note.kill();
+    activeNotes.remove(note, true);
+    note.destroy();
+  }
+
+  function goodNoteHit(note:Note):Void
+  {
+    if (!note.wasGoodHit)
+    {
+      var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true);
+      dispatchEvent(event);
+
+      // Calling event.cancelEvent() skips all the other logic! Neat!
+      if (event.eventCanceled)
+        return;
+
+      if (!note.isSustainNote)
+      {
+        Highscore.tallies.combo++;
+        Highscore.tallies.totalNotesHit++;
+
+        if (Highscore.tallies.combo > Highscore.tallies.maxCombo)
+          Highscore.tallies.maxCombo = Highscore.tallies.combo;
+
+        popUpScore(note.data.strumTime, note);
+      }
+
+      playerStrumline.getArrow(note.data.noteData).playAnimation('confirm', true);
+
+      note.wasGoodHit = true;
+      vocals.volume = 1;
+
+      if (!note.isSustainNote)
+      {
+        note.kill();
+        activeNotes.remove(note, true);
+        note.destroy();
+      }
+    }
+  }
+
+  override function stepHit():Bool
+  {
+    if (SongLoad.songData == null)
+      return false;
+
+    // super.stepHit() returns false if a module cancelled the event.
+    if (!super.stepHit())
+      return false;
+
+    if (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 20
+      || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 20)
+    {
+      resyncVocals();
+    }
+
+    if (iconP1 != null)
+      iconP1.onStepHit(Std.int(Conductor.currentStep));
+    if (iconP2 != null)
+      iconP2.onStepHit(Std.int(Conductor.currentStep));
+
+    return true;
+  }
+
+  override function beatHit():Bool
+  {
+    // super.beatHit() returns false if a module cancelled the event.
+    if (!super.beatHit())
+      return false;
+
+    if (generatedMusic)
+    {
+      // TODO: Sort more efficiently, or less often, to improve performance.
+      activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING);
+    }
+
+    // Moving this code into the `beatHit` function allows for scripts and modules to control the camera better.
+    if (currentSong != null)
+    {
+      if (generatedMusic && SongLoad.getSong()[Std.int(Conductor.currentStep / 16)] != null)
+      {
+        // cameraRightSide = SongLoad.getSong()[Std.int(Conductor.currentStep / 16)].mustHitSection;
+      }
+
+      if (SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)] != null)
+      {
+        if (SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)].changeBPM)
+        {
+          Conductor.forceBPM(SongLoad.getSong()[Math.floor(Conductor.currentStep / 16)].bpm);
+          FlxG.log.add('CHANGED BPM!');
+        }
+      }
+    }
+
+    // Manage the camera focus, if necessary.
+    // controlCamera();
+
+    // HARDCODING FOR MILF ZOOMS!
+
+    if (PreferencesMenu.getPref('camera-zoom'))
+    {
+      if (currentSong != null
+        && currentSong.song.toLowerCase() == 'milf'
+        && Conductor.currentBeat >= 168
+        && Conductor.currentBeat < 200
+        && camZooming
+        && FlxG.camera.zoom < 1.35)
+      {
+        FlxG.camera.zoom += 0.015 * FlxCamera.defaultZoom;
+        camHUD.zoom += 0.03;
+      }
+
+      if (camZooming && FlxG.camera.zoom < (1.35 * FlxCamera.defaultZoom) && Conductor.currentBeat % 4 == 0)
+      {
+        FlxG.camera.zoom += 0.015 * FlxCamera.defaultZoom;
+        camHUD.zoom += 0.03;
+      }
+    }
+
+    // That combo counter that got spoiled that one time.
+    // Comes with NEAT visual and audio effects.
+
+    // bruh this var is bonkers i thot it was a function lmfaooo
+
+    // Break up into individual lines to aid debugging.
+
+    var shouldShowComboText:Bool = false;
+    if (currentSong != null)
+    {
+      shouldShowComboText = (Conductor.currentBeat % 8 == 7);
+      var daSection = SongLoad.getSong()[Std.int(Conductor.currentBeat / 16)];
+      shouldShowComboText = shouldShowComboText && (daSection != null && daSection.mustHitSection);
+      shouldShowComboText = shouldShowComboText && (Highscore.tallies.combo > 5);
+
+      var daNextSection = SongLoad.getSong()[Std.int(Conductor.currentBeat / 16) + 1];
+      var isEndOfSong = SongLoad.getSong().length < Std.int(Conductor.currentBeat / 16);
+      shouldShowComboText = shouldShowComboText && (isEndOfSong || (daNextSection != null && !daNextSection.mustHitSection));
+    }
+
+    if (shouldShowComboText)
+    {
+      var animShit:ComboCounter = new ComboCounter(-100, 300, Highscore.tallies.combo);
+      animShit.scrollFactor.set(0.6, 0.6);
+      animShit.cameras = [camHUD];
+      add(animShit);
+
+      var frameShit:Float = (1 / 24) * 2; // equals 2 frames in the animation
+
+      new FlxTimer().start(((Conductor.crochet / 1000) * 1.25) - frameShit, function(tmr)
+      {
+        animShit.forceFinish();
+      });
+    }
+
+    // Make the characters dance on the beat
+    danceOnBeat();
+
+    return true;
+  }
+
+  /**
+   * Handles characters dancing to the beat of the current song.
+   * 
+   * TODO: Move some of this logic into `Bopper.hx`
+   */
+  public function danceOnBeat()
+  {
+    if (currentStage == null)
+      return;
+
+    // TODO: Move this to a song event.
+    if (Conductor.currentBeat % 16 == 15 // && currentSong.song == 'Tutorial'
+      && currentStage.getDad().characterId == 'gf'
+      && Conductor.currentBeat > 16
+      && Conductor.currentBeat < 48)
+    {
+      currentStage.getBoyfriend().playAnimation('hey', true);
+      currentStage.getDad().playAnimation('cheer', true);
+    }
+  }
+
+  /**
+   * Constructs the strumlines for each player.
+   */
+  function buildStrumlines():Void
+  {
+    var strumlineStyle:StrumlineStyle = NORMAL;
+
+    // TODO: Put this in the chart or something?
+    switch (currentStageId)
+    {
+      case 'school':
+        strumlineStyle = PIXEL;
+      case 'schoolEvil':
+        strumlineStyle = PIXEL;
+    }
+
+    var strumlineYPos = Strumline.getYPos();
+
+    playerStrumline = new Strumline(0, strumlineStyle, 4);
+    playerStrumline.x = 50 + FlxG.width / 2;
+    playerStrumline.y = strumlineYPos;
+    // Set the z-index so they don't appear in front of notes.
+    playerStrumline.zIndex = 100;
+    add(playerStrumline);
+    playerStrumline.cameras = [camHUD];
+
+    if (!isStoryMode)
+    {
+      playerStrumline.fadeInArrows();
+    }
+
+    enemyStrumline = new Strumline(1, strumlineStyle, 4);
+    enemyStrumline.x = 50;
+    enemyStrumline.y = strumlineYPos;
+    // Set the z-index so they don't appear in front of notes.
+    enemyStrumline.zIndex = 100;
+    add(enemyStrumline);
+    enemyStrumline.cameras = [camHUD];
+
+    if (!isStoryMode)
+    {
+      enemyStrumline.fadeInArrows();
+    }
+
+    this.refresh();
+  }
+
+  /**
+   * Function called before opening a new substate.
+   * @param subState The substate to open.
+   */
+  override function openSubState(subState:FlxSubState)
+  {
+    // If there is a substate which requires the game to continue,
+    // then make this a condition.
+    var shouldPause = true;
+
+    if (shouldPause)
+    {
+      // Pause the music.
+      if (FlxG.sound.music != null)
+      {
+        FlxG.sound.music.pause();
+        if (vocals != null)
+          vocals.pause();
+      }
+
+      // Pause the countdown.
+      Countdown.pauseCountdown();
+    }
+
+    super.openSubState(subState);
+  }
+
+  /**
+   * Function called before closing the current substate.
+   * @param subState 
+   */
+  override function closeSubState()
+  {
+    if (isGamePaused)
+    {
+      var event:ScriptEvent = new ScriptEvent(ScriptEvent.RESUME, true);
+
+      dispatchEvent(event);
+
+      if (event.eventCanceled)
+        return;
+
+      if (FlxG.sound.music != null && !startingSong && !isInCutscene)
+        resyncVocals();
+
+      // Resume the countdown.
+      Countdown.resumeCountdown();
+
+      #if discord_rpc
+      if (startTimer.finished)
+        DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, songLength - Conductor.songPosition);
+      else
+        DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
+      #end
+    }
+
+    super.closeSubState();
+  }
+
+  /**
+   * Prepares to start the countdown.
+   * Ends any running cutscenes, creates the strumlines, and starts the countdown.
+   */
+  function startCountdown():Void
+  {
+    var result = Countdown.performCountdown(currentStageId.startsWith('school'));
+    if (!result)
+      return;
+
+    isInCutscene = false;
+    camHUD.visible = true;
+    talking = false;
+
+    buildStrumlines();
+  }
+
+  override function dispatchEvent(event:ScriptEvent):Void
+  {
+    // ORDER: Module, Stage, Character, Song, Note
+    // Modules should get the first chance to cancel the event.
+
+    // super.dispatchEvent(event) dispatches event to module scripts.
+    super.dispatchEvent(event);
+
+    // Dispatch event to stage script.
+    ScriptEventDispatcher.callEvent(currentStage, event);
+
+    // Dispatch event to character script(s).
+    if (currentStage != null)
+      currentStage.dispatchToCharacters(event);
+
+    // TODO: Dispatch event to song script
+  }
+
+  /**
+   * Updates the position and contents of the score display.
+   */
+  function updateScoreText():Void
+  {
+    // TODO: Add functionality for modules to update the score text.
+    scoreText.text = "Score:" + songScore;
+  }
+
+  /**
+   * Updates the values of the health bar.
+   */
+  function updateHealthBar():Void
+  {
+    healthLerp = FlxMath.lerp(healthLerp, health, 0.15);
+  }
+
+  /**
+   * Resets the camera's zoom level and focus point.
+   */
+  public function resetCamera():Void
+  {
+    FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.04);
+    FlxG.camera.targetOffset.set();
+    FlxG.camera.zoom = defaultCameraZoom;
+    FlxG.camera.focusOn(cameraFollowPoint.getPosition());
+  }
+
+  /**
+   * Perform necessary cleanup before leaving the PlayState.
+   */
+  function performCleanup()
+  {
+    // Uncache the song.
+    if (currentChart != null) {}
+    else if (currentSong != null)
+    {
+      openfl.utils.Assets.cache.clear(Paths.inst(currentSong.song));
+      openfl.utils.Assets.cache.clear(Paths.voices(currentSong.song));
+    }
+
+    // Remove reference to stage and remove sprites from it to save memory.
+    if (currentStage != null)
+    {
+      remove(currentStage);
+      currentStage.kill();
+      dispatchEvent(new ScriptEvent(ScriptEvent.DESTROY, false));
+      currentStage = null;
+    }
+
+    GameOverSubstate.reset();
+
+    // Clear the static reference to this state.
+    instance = null;
+  }
+
+  /**
+   * This function is called whenever Flixel switches switching to a new FlxState.
+   * @return Whether to actually switch to the new state.
+   */
+  override function switchTo(nextState:FlxState):Bool
+  {
+    var result = super.switchTo(nextState);
+
+    if (result)
+    {
+      performCleanup();
+    }
+
+    return result;
+  }
 }
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index 2eaf4f944..7dcbf9cf6 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -18,589 +18,589 @@ import openfl.utils.Assets;
 
 class CharacterDataParser
 {
-	/**
-	 * 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 CHARACTER_DATA_VERSION:String = "1.0.0";
+  /**
+   * 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 CHARACTER_DATA_VERSION:String = "1.0.0";
 
-	/**
-	 * The current version rule check for the stage data format.
-	 */
-	public static final CHARACTER_DATA_VERSION_RULE:String = "1.0.x";
+  /**
+   * The current version rule check for the stage data format.
+   */
+  public static final CHARACTER_DATA_VERSION_RULE:String = "1.0.x";
 
-	static final characterCache:Map<String, CharacterData> = new Map<String, CharacterData>();
-	static final characterScriptedClass:Map<String, String> = new Map<String, String>();
+  static final characterCache:Map<String, CharacterData> = new Map<String, CharacterData>();
+  static final characterScriptedClass:Map<String, String> = new Map<String, String>();
 
-	static final DEFAULT_CHAR_ID:String = 'UNKNOWN';
+  static final DEFAULT_CHAR_ID:String = 'UNKNOWN';
 
-	/**
-	 * Parses and preloads the game's stage data and scripts when the game starts.
-	 * 
-	 * If you want to force stages to be reloaded, you can just call this function again.
-	 */
-	public static function loadCharacterCache():Void
-	{
-		// Clear any stages that are cached if there were any.
-		clearCharacterCache();
-		trace("[CHARDATA] Loading character cache...");
+  /**
+   * Parses and preloads the game's stage data and scripts when the game starts.
+   * 
+   * If you want to force stages to be reloaded, you can just call this function again.
+   */
+  public static function loadCharacterCache():Void
+  {
+    // Clear any stages that are cached if there were any.
+    clearCharacterCache();
+    trace("Loading character cache...");
 
-		//
-		// UNSCRIPTED CHARACTERS
-		//
-		var charIdList:Array<String> = DataAssets.listDataFilesInPath('characters/');
-		var unscriptedCharIds:Array<String> = charIdList.filter(function(charId:String):Bool
-		{
-			return !characterCache.exists(charId);
-		});
-		trace('  Fetching data for ${unscriptedCharIds.length} characters...');
-		for (charId in unscriptedCharIds)
-		{
-			try
-			{
-				var charData:CharacterData = parseCharacterData(charId);
-				if (charData != null)
-				{
-					trace('    Loaded character data: ${charId}');
-					characterCache.set(charId, charData);
-				}
-			}
-			catch (e)
-			{
-				// Assume error was already logged.
-				continue;
-			}
-		}
+    //
+    // UNSCRIPTED CHARACTERS
+    //
+    var charIdList:Array<String> = DataAssets.listDataFilesInPath('characters/');
+    var unscriptedCharIds:Array<String> = charIdList.filter(function(charId:String):Bool
+    {
+      return !characterCache.exists(charId);
+    });
+    trace('  Fetching data for ${unscriptedCharIds.length} characters...');
+    for (charId in unscriptedCharIds)
+    {
+      try
+      {
+        var charData:CharacterData = parseCharacterData(charId);
+        if (charData != null)
+        {
+          trace('    Loaded character data: ${charId}');
+          characterCache.set(charId, charData);
+        }
+      }
+      catch (e)
+      {
+        // Assume error was already logged.
+        continue;
+      }
+    }
 
-		//
-		// SCRIPTED CHARACTERS
-		//
+    //
+    // SCRIPTED CHARACTERS
+    //
 
-		// Fuck I wish scripted classes supported static functions.
+    // Fuck I wish scripted classes supported static functions.
 
-		var scriptedCharClassNames1:Array<String> = ScriptedSparrowCharacter.listScriptClasses();
-		if (scriptedCharClassNames1.length > 0)
-		{
-			trace('  Instantiating ${scriptedCharClassNames1.length} (Sparrow) scripted characters...');
-			for (charCls in scriptedCharClassNames1)
-			{
-				var character = ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID);
-				characterScriptedClass.set(character.characterId, charCls);
-			}
-		}
+    var scriptedCharClassNames1:Array<String> = ScriptedSparrowCharacter.listScriptClasses();
+    if (scriptedCharClassNames1.length > 0)
+    {
+      trace('  Instantiating ${scriptedCharClassNames1.length} (Sparrow) scripted characters...');
+      for (charCls in scriptedCharClassNames1)
+      {
+        var character = ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID);
+        characterScriptedClass.set(character.characterId, charCls);
+      }
+    }
 
-		var scriptedCharClassNames2:Array<String> = ScriptedPackerCharacter.listScriptClasses();
-		if (scriptedCharClassNames2.length > 0)
-		{
-			trace('  Instantiating ${scriptedCharClassNames2.length} (Packer) scripted characters...');
-			for (charCls in scriptedCharClassNames2)
-			{
-				var character = ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID);
-				characterScriptedClass.set(character.characterId, charCls);
-			}
-		}
+    var scriptedCharClassNames2:Array<String> = ScriptedPackerCharacter.listScriptClasses();
+    if (scriptedCharClassNames2.length > 0)
+    {
+      trace('  Instantiating ${scriptedCharClassNames2.length} (Packer) scripted characters...');
+      for (charCls in scriptedCharClassNames2)
+      {
+        var character = ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID);
+        characterScriptedClass.set(character.characterId, charCls);
+      }
+    }
 
-		var scriptedCharClassNames3:Array<String> = ScriptedMultiSparrowCharacter.listScriptClasses();
-		if (scriptedCharClassNames3.length > 0)
-		{
-			trace('  Instantiating ${scriptedCharClassNames3.length} (Multi-Sparrow) scripted characters...');
-			for (charCls in scriptedCharClassNames3)
-			{
-				var character = ScriptedMultiSparrowCharacter.init(charCls, DEFAULT_CHAR_ID);
-				if (character == null)
-				{
-					trace('    Failed to instantiate scripted character: ${charCls}');
-					continue;
-				}
-				characterScriptedClass.set(character.characterId, charCls);
-			}
-		}
+    var scriptedCharClassNames3:Array<String> = ScriptedMultiSparrowCharacter.listScriptClasses();
+    if (scriptedCharClassNames3.length > 0)
+    {
+      trace('  Instantiating ${scriptedCharClassNames3.length} (Multi-Sparrow) scripted characters...');
+      for (charCls in scriptedCharClassNames3)
+      {
+        var character = ScriptedMultiSparrowCharacter.init(charCls, DEFAULT_CHAR_ID);
+        if (character == null)
+        {
+          trace('    Failed to instantiate scripted character: ${charCls}');
+          continue;
+        }
+        characterScriptedClass.set(character.characterId, charCls);
+      }
+    }
 
-		// NOTE: Only instantiate the ones not populated above.
-		// ScriptedBaseCharacter.listScriptClasses() will pick up scripts extending the other classes.
-		var scriptedCharClassNames:Array<String> = ScriptedBaseCharacter.listScriptClasses();
-		scriptedCharClassNames = scriptedCharClassNames.filter(function(charCls:String):Bool
-		{
-			return !(scriptedCharClassNames1.contains(charCls)
-				|| scriptedCharClassNames2.contains(charCls)
-				|| scriptedCharClassNames3.contains(charCls));
-		});
+    // NOTE: Only instantiate the ones not populated above.
+    // ScriptedBaseCharacter.listScriptClasses() will pick up scripts extending the other classes.
+    var scriptedCharClassNames:Array<String> = ScriptedBaseCharacter.listScriptClasses();
+    scriptedCharClassNames = scriptedCharClassNames.filter(function(charCls:String):Bool
+    {
+      return !(scriptedCharClassNames1.contains(charCls)
+        || scriptedCharClassNames2.contains(charCls)
+        || scriptedCharClassNames3.contains(charCls));
+    });
 
-		if (scriptedCharClassNames.length > 0)
-		{
-			trace('  Instantiating ${scriptedCharClassNames.length} (Base) scripted characters...');
-			for (charCls in scriptedCharClassNames)
-			{
-				var character = ScriptedBaseCharacter.init(charCls, DEFAULT_CHAR_ID);
-				if (character == null)
-				{
-					trace('    Failed to instantiate scripted character: ${charCls}');
-					continue;
-				}
-				else
-				{
-					trace('    Successfully instantiated scripted character: ${charCls}');
-					characterScriptedClass.set(character.characterId, charCls);
-				}
-			}
-		}
+    if (scriptedCharClassNames.length > 0)
+    {
+      trace('  Instantiating ${scriptedCharClassNames.length} (Base) scripted characters...');
+      for (charCls in scriptedCharClassNames)
+      {
+        var character = ScriptedBaseCharacter.init(charCls, DEFAULT_CHAR_ID);
+        if (character == null)
+        {
+          trace('    Failed to instantiate scripted character: ${charCls}');
+          continue;
+        }
+        else
+        {
+          trace('    Successfully instantiated scripted character: ${charCls}');
+          characterScriptedClass.set(character.characterId, charCls);
+        }
+      }
+    }
 
-		trace('  Successfully loaded ${Lambda.count(characterCache)} stages.');
-	}
+    trace('  Successfully loaded ${Lambda.count(characterCache)} stages.');
+  }
 
-	public static function fetchCharacter(charId:String):Null<BaseCharacter>
-	{
-		if (charId == null || charId == '')
-		{
-			// Gracefully handle songs that don't use this character.
-			return null;
-		}
+  public static function fetchCharacter(charId:String):Null<BaseCharacter>
+  {
+    if (charId == null || charId == '')
+    {
+      // Gracefully handle songs that don't use this character.
+      return null;
+    }
 
-		if (characterCache.exists(charId))
-		{
-			var charData:CharacterData = characterCache.get(charId);
-			var charScriptClass:String = characterScriptedClass.get(charId);
+    if (characterCache.exists(charId))
+    {
+      var charData:CharacterData = characterCache.get(charId);
+      var charScriptClass:String = characterScriptedClass.get(charId);
 
-			var char:BaseCharacter;
+      var char:BaseCharacter;
 
-			if (charScriptClass != null)
-			{
-				switch (charData.renderType)
-				{
-					case CharacterRenderType.MULTISPARROW:
-						char = ScriptedMultiSparrowCharacter.init(charScriptClass, charId);
-					case CharacterRenderType.SPARROW:
-						char = ScriptedSparrowCharacter.init(charScriptClass, charId);
-					case CharacterRenderType.PACKER:
-						char = ScriptedPackerCharacter.init(charScriptClass, charId);
-					default:
-						// We're going to assume that the script class does the rendering.
-						char = ScriptedBaseCharacter.init(charScriptClass, charId);
-				}
-			}
-			else
-			{
-				switch (charData.renderType)
-				{
-					case CharacterRenderType.MULTISPARROW:
-						char = new MultiSparrowCharacter(charId);
-					case CharacterRenderType.SPARROW:
-						char = new SparrowCharacter(charId);
-					case CharacterRenderType.PACKER:
-						char = new PackerCharacter(charId);
-					default:
-						trace('[WARN] Creating character with undefined renderType ${charData.renderType}');
-						char = new BaseCharacter(charId);
-				}
-			}
+      if (charScriptClass != null)
+      {
+        switch (charData.renderType)
+        {
+          case CharacterRenderType.MULTISPARROW:
+            char = ScriptedMultiSparrowCharacter.init(charScriptClass, charId);
+          case CharacterRenderType.SPARROW:
+            char = ScriptedSparrowCharacter.init(charScriptClass, charId);
+          case CharacterRenderType.PACKER:
+            char = ScriptedPackerCharacter.init(charScriptClass, charId);
+          default:
+            // We're going to assume that the script class does the rendering.
+            char = ScriptedBaseCharacter.init(charScriptClass, charId);
+        }
+      }
+      else
+      {
+        switch (charData.renderType)
+        {
+          case CharacterRenderType.MULTISPARROW:
+            char = new MultiSparrowCharacter(charId);
+          case CharacterRenderType.SPARROW:
+            char = new SparrowCharacter(charId);
+          case CharacterRenderType.PACKER:
+            char = new PackerCharacter(charId);
+          default:
+            trace('[WARN] Creating character with undefined renderType ${charData.renderType}');
+            char = new BaseCharacter(charId);
+        }
+      }
 
-			trace('[CHARDATA] Successfully instantiated character: ${charId}');
+      trace('Successfully instantiated character: ${charId}');
 
-			// Call onCreate only in the fetchCharacter() function, not at application initialization.
-			ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE));
+      // Call onCreate only in the fetchCharacter() function, not at application initialization.
+      ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE));
 
-			return char;
-		}
-		else
-		{
-			trace('[CHARDATA] Failed to build character, not found in cache: ${charId}');
-			return null;
-		}
-	}
+      return char;
+    }
+    else
+    {
+      trace('Failed to build character, not found in cache: ${charId}');
+      return null;
+    }
+  }
 
-	public static function fetchCharacterData(charId:String):Null<CharacterData>
-	{
-		if (characterCache.exists(charId))
-		{
-			return characterCache.get(charId);
-		}
-		else
-		{
-			return null;
-		}
-	}
+  public static function fetchCharacterData(charId:String):Null<CharacterData>
+  {
+    if (characterCache.exists(charId))
+    {
+      return characterCache.get(charId);
+    }
+    else
+    {
+      return null;
+    }
+  }
 
-	public static function listCharacterIds():Array<String>
-	{
-		return characterCache.keys().array();
-	}
+  public static function listCharacterIds():Array<String>
+  {
+    return characterCache.keys().array();
+  }
 
-	static function clearCharacterCache():Void
-	{
-		if (characterCache != null)
-		{
-			characterCache.clear();
-		}
-		if (characterScriptedClass != null)
-		{
-			characterScriptedClass.clear();
-		}
-	}
+  static function clearCharacterCache():Void
+  {
+    if (characterCache != null)
+    {
+      characterCache.clear();
+    }
+    if (characterScriptedClass != null)
+    {
+      characterScriptedClass.clear();
+    }
+  }
 
-	/**
-	 * Load a character's JSON file, parse its data, and return it.
-	 * 
-	 * @param charId The character to load.
-	 * @return The character data, or null if validation failed.
-	 */
-	public static function parseCharacterData(charId:String):Null<CharacterData>
-	{
-		var rawJson:String = loadCharacterFile(charId);
+  /**
+   * Load a character's JSON file, parse its data, and return it.
+   * 
+   * @param charId The character to load.
+   * @return The character data, or null if validation failed.
+   */
+  public static function parseCharacterData(charId:String):Null<CharacterData>
+  {
+    var rawJson:String = loadCharacterFile(charId);
 
-		var charData:CharacterData = migrateCharacterData(rawJson, charId);
+    var charData:CharacterData = migrateCharacterData(rawJson, charId);
 
-		return validateCharacterData(charId, charData);
-	}
+    return validateCharacterData(charId, charData);
+  }
 
-	static function loadCharacterFile(charPath:String):String
-	{
-		var charFilePath:String = Paths.json('characters/${charPath}');
-		var rawJson = Assets.getText(charFilePath).trim();
+  static function loadCharacterFile(charPath:String):String
+  {
+    var charFilePath:String = Paths.json('characters/${charPath}');
+    var rawJson = Assets.getText(charFilePath).trim();
 
-		while (!StringTools.endsWith(rawJson, "}"))
-		{
-			rawJson = rawJson.substr(0, rawJson.length - 1);
-		}
+    while (!StringTools.endsWith(rawJson, "}"))
+    {
+      rawJson = rawJson.substr(0, rawJson.length - 1);
+    }
 
-		return rawJson;
-	}
+    return rawJson;
+  }
 
-	static function migrateCharacterData(rawJson:String, charId:String)
-	{
-		// If you update the character data format in a breaking way,
-		// handle migration here by checking the `version` value.
+  static function migrateCharacterData(rawJson:String, charId:String)
+  {
+    // If you update the character data format in a breaking way,
+    // handle migration here by checking the `version` value.
 
-		try
-		{
-			var charData:CharacterData = cast Json.parse(rawJson);
-			return charData;
-		}
-		catch (e)
-		{
-			trace('  Error parsing data for character: ${charId}');
-			trace('    ${e}');
-			return null;
-		}
-	}
+    try
+    {
+      var charData:CharacterData = cast Json.parse(rawJson);
+      return charData;
+    }
+    catch (e)
+    {
+      trace('  Error parsing data for character: ${charId}');
+      trace('    ${e}');
+      return null;
+    }
+  }
 
-	/**
-	 * The default time the character should sing for, in beats.
-	 * Values that are too low will cause the character to stop singing between notes.
-	 * Originally, this value was set to 1, but it was changed to 2 because that became
-	 * too low after some other code changes.
-	 */
-	static final DEFAULT_SINGTIME:Float = 2.0;
+  /**
+   * The default time the character should sing for, in beats.
+   * Values that are too low will cause the character to stop singing between notes.
+   * Originally, this value was set to 1, but it was changed to 2 because that became
+   * too low after some other code changes.
+   */
+  static final DEFAULT_SINGTIME:Float = 2.0;
 
-	static final DEFAULT_DANCEEVERY:Int = 1;
-	static final DEFAULT_FLIPX:Bool = false;
-	static final DEFAULT_FLIPY:Bool = false;
-	static final DEFAULT_FRAMERATE:Int = 24;
-	static final DEFAULT_ISPIXEL:Bool = false;
-	static final DEFAULT_LOOP:Bool = false;
-	static final DEFAULT_NAME:String = "Untitled Character";
-	static final DEFAULT_OFFSETS:Array<Float> = [0, 0];
-	static final DEFAULT_HEALTHICON_OFFSETS:Array<Int> = [0, 25];
-	static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW;
-	static final DEFAULT_SCALE:Float = 1;
-	static final DEFAULT_SCROLL:Array<Float> = [0, 0];
-	static final DEFAULT_STARTINGANIM:String = "idle";
+  static final DEFAULT_DANCEEVERY:Int = 1;
+  static final DEFAULT_FLIPX:Bool = false;
+  static final DEFAULT_FLIPY:Bool = false;
+  static final DEFAULT_FRAMERATE:Int = 24;
+  static final DEFAULT_ISPIXEL:Bool = false;
+  static final DEFAULT_LOOP:Bool = false;
+  static final DEFAULT_NAME:String = "Untitled Character";
+  static final DEFAULT_OFFSETS:Array<Float> = [0, 0];
+  static final DEFAULT_HEALTHICON_OFFSETS:Array<Int> = [0, 25];
+  static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW;
+  static final DEFAULT_SCALE:Float = 1;
+  static final DEFAULT_SCROLL:Array<Float> = [0, 0];
+  static final DEFAULT_STARTINGANIM:String = "idle";
 
-	/**
-	 * Set unspecified parameters to their defaults.
-	 * If the parameter is mandatory, print an error message.
-	 * @param id 
-	 * @param input 
-	 * @return The validated character data
-	 */
-	static function validateCharacterData(id:String, input:CharacterData):Null<CharacterData>
-	{
-		if (input == null)
-		{
-			// trace('[CHARDATA] ERROR: Could not parse character data for "${id}".');
-			return null;
-		}
+  /**
+   * Set unspecified parameters to their defaults.
+   * If the parameter is mandatory, print an error message.
+   * @param id 
+   * @param input 
+   * @return The validated character data
+   */
+  static function validateCharacterData(id:String, input:CharacterData):Null<CharacterData>
+  {
+    if (input == null)
+    {
+      // trace('ERROR: Could not parse character data for "${id}".');
+      return null;
+    }
 
-		if (input.version == null)
-		{
-			trace('[CHARDATA] WARN: No semantic version specified for character data file "$id", assuming ${CHARACTER_DATA_VERSION}');
-			input.version = CHARACTER_DATA_VERSION;
-		}
+    if (input.version == null)
+    {
+      trace('WARN: No semantic version specified for character data file "$id", assuming ${CHARACTER_DATA_VERSION}');
+      input.version = CHARACTER_DATA_VERSION;
+    }
 
-		if (!VersionUtil.validateVersion(input.version, CHARACTER_DATA_VERSION_RULE))
-		{
-			trace('[CHARDATA] ERROR: Could not load character data for "$id": bad version (got ${input.version}, expected ${CHARACTER_DATA_VERSION_RULE})');
-			return null;
-		}
+    if (!VersionUtil.validateVersion(input.version, CHARACTER_DATA_VERSION_RULE))
+    {
+      trace('ERROR: Could not load character data for "$id": bad version (got ${input.version}, expected ${CHARACTER_DATA_VERSION_RULE})');
+      return null;
+    }
 
-		if (input.name == null)
-		{
-			trace('[CHARDATA] WARN: Character data for "$id" missing name');
-			input.name = DEFAULT_NAME;
-		}
+    if (input.name == null)
+    {
+      trace('WARN: Character data for "$id" missing name');
+      input.name = DEFAULT_NAME;
+    }
 
-		if (input.renderType == null)
-		{
-			input.renderType = DEFAULT_RENDERTYPE;
-		}
+    if (input.renderType == null)
+    {
+      input.renderType = DEFAULT_RENDERTYPE;
+    }
 
-		if (input.assetPath == null)
-		{
-			trace('[CHARDATA] ERROR: Could not load character data for "$id": missing assetPath');
-			return null;
-		}
+    if (input.assetPath == null)
+    {
+      trace('ERROR: Could not load character data for "$id": missing assetPath');
+      return null;
+    }
 
-		if (input.offsets == null)
-		{
-			input.offsets = DEFAULT_OFFSETS;
-		}
+    if (input.offsets == null)
+    {
+      input.offsets = DEFAULT_OFFSETS;
+    }
 
-		if (input.cameraOffsets == null)
-		{
-			input.cameraOffsets = DEFAULT_OFFSETS;
-		}
+    if (input.cameraOffsets == null)
+    {
+      input.cameraOffsets = DEFAULT_OFFSETS;
+    }
 
-		if (input.healthIcon == null)
-		{
-			input.healthIcon = {
-				id: null,
-				scale: null,
-				flipX: null,
-				offsets: null
-			};
-		}
+    if (input.healthIcon == null)
+    {
+      input.healthIcon = {
+        id: null,
+        scale: null,
+        flipX: null,
+        offsets: null
+      };
+    }
 
-		if (input.healthIcon.id == null)
-		{
-			input.healthIcon.id = id;
-		}
+    if (input.healthIcon.id == null)
+    {
+      input.healthIcon.id = id;
+    }
 
-		if (input.healthIcon.scale == null)
-		{
-			input.healthIcon.scale = DEFAULT_SCALE;
-		}
+    if (input.healthIcon.scale == null)
+    {
+      input.healthIcon.scale = DEFAULT_SCALE;
+    }
 
-		if (input.healthIcon.flipX == null)
-		{
-			input.healthIcon.flipX = DEFAULT_FLIPX;
-		}
+    if (input.healthIcon.flipX == null)
+    {
+      input.healthIcon.flipX = DEFAULT_FLIPX;
+    }
 
-		if (input.healthIcon.offsets == null)
-		{
-			input.healthIcon.offsets = DEFAULT_OFFSETS;
-		}
+    if (input.healthIcon.offsets == null)
+    {
+      input.healthIcon.offsets = DEFAULT_OFFSETS;
+    }
 
-		if (input.startingAnimation == null)
-		{
-			input.startingAnimation = DEFAULT_STARTINGANIM;
-		}
+    if (input.startingAnimation == null)
+    {
+      input.startingAnimation = DEFAULT_STARTINGANIM;
+    }
 
-		if (input.scale == null)
-		{
-			input.scale = DEFAULT_SCALE;
-		}
+    if (input.scale == null)
+    {
+      input.scale = DEFAULT_SCALE;
+    }
 
-		if (input.isPixel == null)
-		{
-			input.isPixel = DEFAULT_ISPIXEL;
-		}
+    if (input.isPixel == null)
+    {
+      input.isPixel = DEFAULT_ISPIXEL;
+    }
 
-		if (input.danceEvery == null)
-		{
-			input.danceEvery = DEFAULT_DANCEEVERY;
-		}
+    if (input.danceEvery == null)
+    {
+      input.danceEvery = DEFAULT_DANCEEVERY;
+    }
 
-		if (input.singTime == null)
-		{
-			input.singTime = DEFAULT_SINGTIME;
-		}
+    if (input.singTime == null)
+    {
+      input.singTime = DEFAULT_SINGTIME;
+    }
 
-		if (input.animations == null || input.animations.length == 0)
-		{
-			trace('[CHARDATA] ERROR: Could not load character data for "$id": missing animations');
-			input.animations = [];
-		}
+    if (input.animations == null || input.animations.length == 0)
+    {
+      trace('ERROR: Could not load character data for "$id": missing animations');
+      input.animations = [];
+    }
 
-		if (input.flipX == null)
-		{
-			input.flipX = DEFAULT_FLIPX;
-		}
+    if (input.flipX == null)
+    {
+      input.flipX = DEFAULT_FLIPX;
+    }
 
-		if (input.animations.length == 0 && input.startingAnimation != null)
-		{
-			return null;
-		}
+    if (input.animations.length == 0 && input.startingAnimation != null)
+    {
+      return null;
+    }
 
-		for (inputAnimation in input.animations)
-		{
-			if (inputAnimation.name == null)
-			{
-				trace('[CHARDATA] ERROR: Could not load character data for "$id": missing animation name for prop "${input.name}"');
-				return null;
-			}
+    for (inputAnimation in input.animations)
+    {
+      if (inputAnimation.name == null)
+      {
+        trace('ERROR: Could not load character data for "$id": missing animation name for prop "${input.name}"');
+        return null;
+      }
 
-			if (inputAnimation.frameRate == null)
-			{
-				inputAnimation.frameRate = DEFAULT_FRAMERATE;
-			}
+      if (inputAnimation.frameRate == null)
+      {
+        inputAnimation.frameRate = DEFAULT_FRAMERATE;
+      }
 
-			if (inputAnimation.offsets == null)
-			{
-				inputAnimation.offsets = DEFAULT_OFFSETS;
-			}
+      if (inputAnimation.offsets == null)
+      {
+        inputAnimation.offsets = DEFAULT_OFFSETS;
+      }
 
-			if (inputAnimation.looped == null)
-			{
-				inputAnimation.looped = DEFAULT_LOOP;
-			}
+      if (inputAnimation.looped == null)
+      {
+        inputAnimation.looped = DEFAULT_LOOP;
+      }
 
-			if (inputAnimation.flipX == null)
-			{
-				inputAnimation.flipX = DEFAULT_FLIPX;
-			}
+      if (inputAnimation.flipX == null)
+      {
+        inputAnimation.flipX = DEFAULT_FLIPX;
+      }
 
-			if (inputAnimation.flipY == null)
-			{
-				inputAnimation.flipY = DEFAULT_FLIPY;
-			}
-		}
+      if (inputAnimation.flipY == null)
+      {
+        inputAnimation.flipY = DEFAULT_FLIPY;
+      }
+    }
 
-		// All good!
-		return input;
-	}
+    // All good!
+    return input;
+  }
 }
 
 enum abstract CharacterRenderType(String) from String to String
 {
-	var SPARROW = 'sparrow';
-	var PACKER = 'packer';
-	var MULTISPARROW = 'multisparrow';
-	// TODO: FlxSpine?
-	//   https://api.haxeflixel.com/flixel/addons/editors/spine/FlxSpine.html
-	// TODO: Aseprite?
-	//   https://lib.haxe.org/p/openfl-aseprite/
-	// TODO: Animate?
-	//   https://lib.haxe.org/p/flxanimate
-	// TODO: REDACTED
+  var SPARROW = 'sparrow';
+  var PACKER = 'packer';
+  var MULTISPARROW = 'multisparrow';
+  // TODO: FlxSpine?
+  //   https://api.haxeflixel.com/flixel/addons/editors/spine/FlxSpine.html
+  // TODO: Aseprite?
+  //   https://lib.haxe.org/p/openfl-aseprite/
+  // TODO: Animate?
+  //   https://lib.haxe.org/p/flxanimate
+  // TODO: REDACTED
 }
 
 typedef CharacterData =
 {
-	/**
-	 * The sematic version number of the character data JSON format.
-	 */
-	var version:String;
+  /**
+   * The sematic version number of the character data JSON format.
+   */
+  var version:String;
 
-	/**
-	 * The readable name of the character.
-	 */
-	var name:String;
+  /**
+   * The readable name of the character.
+   */
+  var name:String;
 
-	/**
-	 * The type of rendering system to use for the character.
-	 * @default sparrow
-	 */
-	var renderType:CharacterRenderType;
+  /**
+   * The type of rendering system to use for the character.
+   * @default sparrow
+   */
+  var renderType:CharacterRenderType;
 
-	/**
-	 * Behavior varies by render type:
-	 * - SPARROW: Path to retrieve both the spritesheet and the XML data from.
-	 * - PACKER: Path to retrieve both the spritsheet and the TXT data from.
-	 */
-	var assetPath:String;
+  /**
+   * Behavior varies by render type:
+   * - SPARROW: Path to retrieve both the spritesheet and the XML data from.
+   * - PACKER: Path to retrieve both the spritsheet and the TXT data from.
+   */
+  var assetPath:String;
 
-	/**
-	 * The scale of the graphic as a float.
-	 * Pro tip: On pixel-art levels, save the sprites small and set this value to 6 or so to save memory.
-	 * @default 1
-	 */
-	var scale:Null<Float>;
+  /**
+   * The scale of the graphic as a float.
+   * Pro tip: On pixel-art levels, save the sprites small and set this value to 6 or so to save memory.
+   * @default 1
+   */
+  var scale:Null<Float>;
 
-	/**
-	 * Optional data about the health icon for the character.
-	 */
-	var healthIcon:Null<HealthIconData>;
+  /**
+   * Optional data about the health icon for the character.
+   */
+  var healthIcon:Null<HealthIconData>;
 
-	/**
-	 * The global offset to the character's position, in pixels.
-	 * @default [0, 0]
-	 */
-	var offsets:Null<Array<Float>>;
+  /**
+   * The global offset to the character's position, in pixels.
+   * @default [0, 0]
+   */
+  var offsets:Null<Array<Float>>;
 
-	/**
-	 * The amount to offset the camera by while focusing on this character.
-	 * Default value focuses on the character directly.
-	 * @default [0, 0]
-	 */
-	var cameraOffsets:Array<Float>;
+  /**
+   * The amount to offset the camera by while focusing on this character.
+   * Default value focuses on the character directly.
+   * @default [0, 0]
+   */
+  var cameraOffsets:Array<Float>;
 
-	/**
-	 * Setting this to true disables anti-aliasing for the character.
-	 * @default false
-	 */
-	var isPixel:Null<Bool>;
+  /**
+   * Setting this to true disables anti-aliasing for the character.
+   * @default false
+   */
+  var isPixel:Null<Bool>;
 
-	/**
-	 * The frequency at which the character will play its idle animation, in beats.
-	 * Increasing this number will make the character dance less often.
-	 * 
-	 * @default 1
-	 */
-	var danceEvery:Null<Int>;
+  /**
+   * The frequency at which the character will play its idle animation, in beats.
+   * Increasing this number will make the character dance less often.
+   * 
+   * @default 1
+   */
+  var danceEvery:Null<Int>;
 
-	/**
-	 * The minimum duration that a character will play a note animation for, in beats.
-	 * If this number is too low, you may see the character start playing the idle animation between notes.
-	 * If this number is too high, you may see the the character play the sing animation for too long after the notes are gone.
-	 * 
-	 * Examples:
-	 * - Daddy Dearest uses a value of `1.525`.
-	 * @default 1.0
-	 */
-	var singTime:Null<Float>;
+  /**
+   * The minimum duration that a character will play a note animation for, in beats.
+   * If this number is too low, you may see the character start playing the idle animation between notes.
+   * If this number is too high, you may see the the character play the sing animation for too long after the notes are gone.
+   * 
+   * Examples:
+   * - Daddy Dearest uses a value of `1.525`.
+   * @default 1.0
+   */
+  var singTime:Null<Float>;
 
-	/**
-	 * An optional array of animations which the character can play.
-	 */
-	var animations:Array<AnimationData>;
+  /**
+   * An optional array of animations which the character can play.
+   */
+  var animations:Array<AnimationData>;
 
-	/**
-	 * If animations are used, this is the name of the animation to play first.
-	 * @default idle
-	 */
-	var startingAnimation:Null<String>;
+  /**
+   * If animations are used, this is the name of the animation to play first.
+   * @default idle
+   */
+  var startingAnimation:Null<String>;
 
-	/**
-	 * Whether or not the whole ass sprite is flipped by default.
-	 * Useful for characters that could also be played (Pico)
-	 * 
-	 * @default false
-	 */
-	var flipX:Null<Bool>;
+  /**
+   * Whether or not the whole ass sprite is flipped by default.
+   * Useful for characters that could also be played (Pico)
+   * 
+   * @default false
+   */
+  var flipX:Null<Bool>;
 };
 
 typedef HealthIconData =
 {
-	/**
-	 * The ID to use for the health icon.
-	 * @default The character's ID
-	 */
-	var id:Null<String>;
+  /**
+   * The ID to use for the health icon.
+   * @default The character's ID
+   */
+  var id:Null<String>;
 
-	/**
-	 * The scale of the health icon.
-	 */
-	var scale:Null<Float>;
+  /**
+   * The scale of the health icon.
+   */
+  var scale:Null<Float>;
 
-	/**
-	 * Whether to flip the health icon horizontally.
-	 * @default false
-	 */
-	var flipX:Null<Bool>;
+  /**
+   * Whether to flip the health icon horizontally.
+   * @default false
+   */
+  var flipX:Null<Bool>;
 
-	/**
-	 * The offset of the health icon, in pixels.
-	 * @default [0, 25]
-	 */
-	var offsets:Null<Array<Float>>;
+  /**
+   * The offset of the health icon, in pixels.
+   * @default [0, 25]
+   */
+  var offsets:Null<Array<Float>>;
 }
diff --git a/source/funkin/play/character/MultiSparrowCharacter.hx b/source/funkin/play/character/MultiSparrowCharacter.hx
index 132845832..2b539d6e3 100644
--- a/source/funkin/play/character/MultiSparrowCharacter.hx
+++ b/source/funkin/play/character/MultiSparrowCharacter.hx
@@ -20,199 +20,199 @@ import funkin.util.assets.FlxAnimationUtil;
  */
 class MultiSparrowCharacter extends BaseCharacter
 {
-	/**
-	 * The actual group which holds all spritesheets this character uses.
-	 */
-	private var members:Map<String, FlxFramesCollection> = new Map<String, FlxFramesCollection>();
+  /**
+   * The actual group which holds all spritesheets this character uses.
+   */
+  private var members:Map<String, FlxFramesCollection> = new Map<String, FlxFramesCollection>();
 
-	/**
-	 * A map between animation names and what frame collection the animation should use.
-	 */
-	private var animAssetPath:Map<String, String> = new Map<String, String>();
+  /**
+   * A map between animation names and what frame collection the animation should use.
+   */
+  private var animAssetPath:Map<String, String> = new Map<String, String>();
 
-	/**
-	 * The current frame collection being used.
-	 */
-	private var activeMember:String;
+  /**
+   * The current frame collection being used.
+   */
+  private var activeMember:String;
 
-	public function new(id:String)
-	{
-		super(id);
-	}
+  public function new(id:String)
+  {
+    super(id);
+  }
 
-	override function onCreate(event:ScriptEvent):Void
-	{
-		trace('Creating MULTI SPARROW CHARACTER: ' + this.characterId);
+  override function onCreate(event:ScriptEvent):Void
+  {
+    trace('Creating Multi-Sparrow character: ' + this.characterId);
 
-		buildSprites();
-		super.onCreate(event);
-	}
+    buildSprites();
+    super.onCreate(event);
+  }
 
-	function buildSprites()
-	{
-		buildSpritesheets();
-		buildAnimations();
+  function buildSprites()
+  {
+    buildSpritesheets();
+    buildAnimations();
 
-		if (_data.isPixel)
-		{
-			this.antialiasing = false;
-		}
-		else
-		{
-			this.antialiasing = true;
-		}
-	}
+    if (_data.isPixel)
+    {
+      this.antialiasing = false;
+    }
+    else
+    {
+      this.antialiasing = true;
+    }
+  }
 
-	function buildSpritesheets()
-	{
-		// Build the list of asset paths to use.
-		// Ignore nulls and duplicates.
-		var assetList = [_data.assetPath];
-		for (anim in _data.animations)
-		{
-			if (anim.assetPath != null && !assetList.contains(anim.assetPath))
-			{
-				assetList.push(anim.assetPath);
-			}
-			animAssetPath.set(anim.name, anim.assetPath);
-		}
+  function buildSpritesheets()
+  {
+    // Build the list of asset paths to use.
+    // Ignore nulls and duplicates.
+    var assetList = [_data.assetPath];
+    for (anim in _data.animations)
+    {
+      if (anim.assetPath != null && !assetList.contains(anim.assetPath))
+      {
+        assetList.push(anim.assetPath);
+      }
+      animAssetPath.set(anim.name, anim.assetPath);
+    }
 
-		// Load the Sparrow atlas for each path and store them in the members map.
-		for (asset in assetList)
-		{
-			var texture:FlxFramesCollection = Paths.getSparrowAtlas(asset, 'shared');
-			// If we don't do this, the unused textures will be removed as soon as they're loaded.
+    // Load the Sparrow atlas for each path and store them in the members map.
+    for (asset in assetList)
+    {
+      var texture:FlxFramesCollection = Paths.getSparrowAtlas(asset, 'shared');
+      // If we don't do this, the unused textures will be removed as soon as they're loaded.
 
-			if (texture == null)
-			{
-				trace('Multi-Sparrow atlas could not load texture: ${asset}');
-			}
-			else
-			{
-				trace('Adding multi-sparrow atlas: ${asset}');
-				texture.parent.destroyOnNoUse = false;
-				members.set(asset, texture);
-			}
-		}
+      if (texture == null)
+      {
+        trace('Multi-Sparrow atlas could not load texture: ${asset}');
+      }
+      else
+      {
+        trace('Adding multi-sparrow atlas: ${asset}');
+        texture.parent.destroyOnNoUse = false;
+        members.set(asset, texture);
+      }
+    }
 
-		// Use the default frame collection to start.
-		loadFramesByAssetPath(_data.assetPath);
-	}
+    // Use the default frame collection to start.
+    loadFramesByAssetPath(_data.assetPath);
+  }
 
-	/**
-	 * Replace this sprite's animation frames with the ones at this asset path.
-	 */
-	function loadFramesByAssetPath(assetPath:String):Void
-	{
-		if (_data.assetPath == null)
-		{
-			trace('[ERROR] Multi-Sparrow character has no default asset path!');
-			return;
-		}
-		if (assetPath == null)
-		{
-			// trace('Asset path is null, falling back to default. This is normal!');
-			loadFramesByAssetPath(_data.assetPath);
-			return;
-		}
+  /**
+   * Replace this sprite's animation frames with the ones at this asset path.
+   */
+  function loadFramesByAssetPath(assetPath:String):Void
+  {
+    if (_data.assetPath == null)
+    {
+      trace('[ERROR] Multi-Sparrow character has no default asset path!');
+      return;
+    }
+    if (assetPath == null)
+    {
+      // trace('Asset path is null, falling back to default. This is normal!');
+      loadFramesByAssetPath(_data.assetPath);
+      return;
+    }
 
-		if (this.activeMember == assetPath)
-		{
-			// trace('Already using this asset path: ${assetPath}');
-			return;
-		}
+    if (this.activeMember == assetPath)
+    {
+      // trace('Already using this asset path: ${assetPath}');
+      return;
+    }
 
-		if (members.exists(assetPath))
-		{
-			// Switch to a new set of sprites.
-			// trace('Loading frames from asset path: ${assetPath}');
-			this.frames = members.get(assetPath);
-			this.activeMember = assetPath;
-			this.setScale(_data.scale);
-		}
-		else
-		{
-			trace('[WARN] MultiSparrow character ${characterId} could not find asset path: ${assetPath}');
-		}
-	}
+    if (members.exists(assetPath))
+    {
+      // Switch to a new set of sprites.
+      // trace('Loading frames from asset path: ${assetPath}');
+      this.frames = members.get(assetPath);
+      this.activeMember = assetPath;
+      this.setScale(_data.scale);
+    }
+    else
+    {
+      trace('[WARN] MultiSparrow character ${characterId} could not find asset path: ${assetPath}');
+    }
+  }
 
-	/**
-	 * Replace this sprite's animation frames with the ones needed to play this animation.
-	 */
-	function loadFramesByAnimName(animName)
-	{
-		if (animAssetPath.exists(animName))
-		{
-			loadFramesByAssetPath(animAssetPath.get(animName));
-		}
-		else
-		{
-			trace('[WARN] MultiSparrow character ${characterId} could not find animation: ${animName}');
-		}
-	}
+  /**
+   * Replace this sprite's animation frames with the ones needed to play this animation.
+   */
+  function loadFramesByAnimName(animName)
+  {
+    if (animAssetPath.exists(animName))
+    {
+      loadFramesByAssetPath(animAssetPath.get(animName));
+    }
+    else
+    {
+      trace('[WARN] MultiSparrow character ${characterId} could not find animation: ${animName}');
+    }
+  }
 
-	function buildAnimations()
-	{
-		trace('[MULTISPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}');
+  function buildAnimations()
+  {
+    trace('[MULTISPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}');
 
-		// We need to swap to the proper frame collection before adding the animations, I think?
-		for (anim in _data.animations)
-		{
-			loadFramesByAnimName(anim.name);
-			FlxAnimationUtil.addAtlasAnimation(this, anim);
+    // We need to swap to the proper frame collection before adding the animations, I think?
+    for (anim in _data.animations)
+    {
+      loadFramesByAnimName(anim.name);
+      FlxAnimationUtil.addAtlasAnimation(this, anim);
 
-			if (anim.offsets == null)
-			{
-				setAnimationOffsets(anim.name, 0, 0);
-			}
-			else
-			{
-				setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]);
-			}
-		}
+      if (anim.offsets == null)
+      {
+        setAnimationOffsets(anim.name, 0, 0);
+      }
+      else
+      {
+        setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]);
+      }
+    }
 
-		var animNames = this.animation.getNameList();
-		trace('[MULTISPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}');
-	}
+    var animNames = this.animation.getNameList();
+    trace('[MULTISPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}');
+  }
 
-	public override function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void
-	{
-		// Make sure we ignore other animations if we're currently playing a forced one,
-		// unless we're forcing a new animation.
-		if (!this.canPlayOtherAnims && !ignoreOther)
-			return;
+  public override function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void
+  {
+    // Make sure we ignore other animations if we're currently playing a forced one,
+    // unless we're forcing a new animation.
+    if (!this.canPlayOtherAnims && !ignoreOther)
+      return;
 
-		loadFramesByAnimName(name);
-		super.playAnimation(name, restart, ignoreOther);
-	}
+    loadFramesByAnimName(name);
+    super.playAnimation(name, restart, ignoreOther);
+  }
 
-	override function set_frames(value:FlxFramesCollection):FlxFramesCollection
-	{
-		// DISABLE THIS SO WE DON'T DESTROY OUR HARD WORK
-		// WE WILL MAKE SURE TO LOAD THE PROPER SPRITESHEET BEFORE PLAYING AN ANIM
-		// if (animation != null)
-		// {
-		// 	animation.destroyAnimations();
-		// }
+  override function set_frames(value:FlxFramesCollection):FlxFramesCollection
+  {
+    // DISABLE THIS SO WE DON'T DESTROY OUR HARD WORK
+    // WE WILL MAKE SURE TO LOAD THE PROPER SPRITESHEET BEFORE PLAYING AN ANIM
+    // if (animation != null)
+    // {
+    // 	animation.destroyAnimations();
+    // }
 
-		if (value != null)
-		{
-			graphic = value.parent;
-			this.frames = value;
-			this.frame = value.getByIndex(0);
-			this.numFrames = value.numFrames;
-			resetHelpers();
-			this.bakedRotationAngle = 0;
-			this.animation.frameIndex = 0;
-			graphicLoaded();
-		}
-		else
-		{
-			this.frames = null;
-			this.frame = null;
-			this.graphic = null;
-		}
+    if (value != null)
+    {
+      graphic = value.parent;
+      this.frames = value;
+      this.frame = value.getByIndex(0);
+      this.numFrames = value.numFrames;
+      resetHelpers();
+      this.bakedRotationAngle = 0;
+      this.animation.frameIndex = 0;
+      graphicLoaded();
+    }
+    else
+    {
+      this.frames = null;
+      this.frame = null;
+      this.graphic = null;
+    }
 
-		return this.frames;
-	}
+    return this.frames;
+  }
 }
diff --git a/source/funkin/play/character/PackerCharacter.hx b/source/funkin/play/character/PackerCharacter.hx
index 91e44e9f2..00469964f 100644
--- a/source/funkin/play/character/PackerCharacter.hx
+++ b/source/funkin/play/character/PackerCharacter.hx
@@ -11,65 +11,65 @@ import funkin.play.character.BaseCharacter.CharacterType;
  */
 class PackerCharacter extends BaseCharacter
 {
-	public function new(id:String)
-	{
-		super(id);
-	}
+  public function new(id:String)
+  {
+    super(id);
+  }
 
-	override function onCreate(event:ScriptEvent):Void
-	{
-		trace('Creating PACKER CHARACTER: ' + this.characterId);
+  override function onCreate(event:ScriptEvent):Void
+  {
+    trace('Creating Packer character: ' + this.characterId);
 
-		loadSpritesheet();
-		loadAnimations();
+    loadSpritesheet();
+    loadAnimations();
 
-		super.onCreate(event);
-	}
+    super.onCreate(event);
+  }
 
-	function loadSpritesheet()
-	{
-		trace('[PACKERCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}');
+  function loadSpritesheet()
+  {
+    trace('[PACKERCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}');
 
-		var tex:FlxFramesCollection = Paths.getPackerAtlas(_data.assetPath, 'shared');
-		if (tex == null)
-		{
-			trace('Could not load Packer sprite: ${_data.assetPath}');
-			return;
-		}
+    var tex:FlxFramesCollection = Paths.getPackerAtlas(_data.assetPath, 'shared');
+    if (tex == null)
+    {
+      trace('Could not load Packer sprite: ${_data.assetPath}');
+      return;
+    }
 
-		this.frames = tex;
+    this.frames = tex;
 
-		if (_data.isPixel)
-		{
-			this.antialiasing = false;
-		}
-		else
-		{
-			this.antialiasing = true;
-		}
+    if (_data.isPixel)
+    {
+      this.antialiasing = false;
+    }
+    else
+    {
+      this.antialiasing = true;
+    }
 
-		this.setScale(_data.scale);
-	}
+    this.setScale(_data.scale);
+  }
 
-	function loadAnimations()
-	{
-		trace('[PACKERCHAR] Loading ${_data.animations.length} animations for ${characterId}');
+  function loadAnimations()
+  {
+    trace('[PACKERCHAR] Loading ${_data.animations.length} animations for ${characterId}');
 
-		FlxAnimationUtil.addAtlasAnimations(this, _data.animations);
+    FlxAnimationUtil.addAtlasAnimations(this, _data.animations);
 
-		for (anim in _data.animations)
-		{
-			if (anim.offsets == null)
-			{
-				setAnimationOffsets(anim.name, 0, 0);
-			}
-			else
-			{
-				setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]);
-			}
-		}
+    for (anim in _data.animations)
+    {
+      if (anim.offsets == null)
+      {
+        setAnimationOffsets(anim.name, 0, 0);
+      }
+      else
+      {
+        setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]);
+      }
+    }
 
-		var animNames = this.animation.getNameList();
-		trace('[PACKERCHAR] Successfully loaded ${animNames.length} animations for ${characterId}');
-	}
+    var animNames = this.animation.getNameList();
+    trace('[PACKERCHAR] Successfully loaded ${animNames.length} animations for ${characterId}');
+  }
 }
diff --git a/source/funkin/play/character/SparrowCharacter.hx b/source/funkin/play/character/SparrowCharacter.hx
index 927a4c764..e9e9cf423 100644
--- a/source/funkin/play/character/SparrowCharacter.hx
+++ b/source/funkin/play/character/SparrowCharacter.hx
@@ -13,65 +13,65 @@ import flixel.graphics.frames.FlxFramesCollection;
  */
 class SparrowCharacter extends BaseCharacter
 {
-	public function new(id:String)
-	{
-		super(id);
-	}
+  public function new(id:String)
+  {
+    super(id);
+  }
 
-	override function onCreate(event:ScriptEvent):Void
-	{
-		trace('Creating SPARROW CHARACTER: ' + this.characterId);
+  override function onCreate(event:ScriptEvent):Void
+  {
+    trace('Creating Sparrow character: ' + this.characterId);
 
-		loadSpritesheet();
-		loadAnimations();
+    loadSpritesheet();
+    loadAnimations();
 
-		super.onCreate(event);
-	}
+    super.onCreate(event);
+  }
 
-	function loadSpritesheet()
-	{
-		trace('[SPARROWCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}');
+  function loadSpritesheet()
+  {
+    trace('[SPARROWCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}');
 
-		var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath, 'shared');
-		if (tex == null)
-		{
-			trace('Could not load Sparrow sprite: ${_data.assetPath}');
-			return;
-		}
+    var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath, 'shared');
+    if (tex == null)
+    {
+      trace('Could not load Sparrow sprite: ${_data.assetPath}');
+      return;
+    }
 
-		this.frames = tex;
+    this.frames = tex;
 
-		if (_data.isPixel)
-		{
-			this.antialiasing = false;
-		}
-		else
-		{
-			this.antialiasing = true;
-		}
+    if (_data.isPixel)
+    {
+      this.antialiasing = false;
+    }
+    else
+    {
+      this.antialiasing = true;
+    }
 
-		this.setScale(_data.scale);
-	}
+    this.setScale(_data.scale);
+  }
 
-	function loadAnimations()
-	{
-		trace('[SPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}');
+  function loadAnimations()
+  {
+    trace('[SPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}');
 
-		FlxAnimationUtil.addAtlasAnimations(this, _data.animations);
+    FlxAnimationUtil.addAtlasAnimations(this, _data.animations);
 
-		for (anim in _data.animations)
-		{
-			if (anim.offsets == null)
-			{
-				setAnimationOffsets(anim.name, 0, 0);
-			}
-			else
-			{
-				setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]);
-			}
-		}
+    for (anim in _data.animations)
+    {
+      if (anim.offsets == null)
+      {
+        setAnimationOffsets(anim.name, 0, 0);
+      }
+      else
+      {
+        setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]);
+      }
+    }
 
-		var animNames = this.animation.getNameList();
-		trace('[SPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}');
-	}
+    var animNames = this.animation.getNameList();
+    trace('[SPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}');
+  }
 }
diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx
new file mode 100644
index 000000000..cc8f52164
--- /dev/null
+++ b/source/funkin/play/event/FocusCameraSongEvent.hx
@@ -0,0 +1,142 @@
+package funkin.play.event;
+
+import funkin.play.event.SongEvent;
+import funkin.play.song.SongData;
+
+/**
+ * This class represents a handler for a type of song event.
+ * It is used by the ScriptedSongEvent class to handle user-defined events.
+ * 
+ * Example: Focus on Boyfriend:
+ * ```
+ * {
+ *   "e": "FocusCamera",
+ * 	 "v": {
+ * 	 	 "char": 0,
+ *   }
+ * }
+ * ```
+ * 
+ * Example: Focus on 10px above Girlfriend:
+ * ```
+ * {
+ *   "e": "FocusCamera",
+ * 	 "v": {
+ * 	   "char": 2,
+ * 	   "y": -10,
+ *   }
+ * }
+ * ```
+ * 
+ * Example: Focus on (100, 100):
+ * ```
+ * {
+ *   "e": "FocusCamera",
+ *   "v": {
+ *     "char": -1,
+ *     "x": 100,
+ *     "y": 100,
+ *   }
+ * }
+ * ```
+ */
+class FocusCameraSongEvent extends SongEvent
+{
+  public function new()
+  {
+    super('FocusCamera');
+  }
+
+  public override function handleEvent(data:SongEventData)
+  {
+    // Does nothing if there is no PlayState camera or stage.
+    if (PlayState.instance == null || PlayState.instance.currentStage == null)
+      return;
+
+    var posX = data.getFloat('x');
+    if (posX == null)
+      posX = 0.0;
+    var posY = data.getFloat('y');
+    if (posY == null)
+      posY = 0.0;
+
+    var char = data.getInt('char');
+
+    if (char == null)
+      char = cast data.value;
+
+    switch (char)
+    {
+      case -1: // Position
+        trace('Focusing camera on static position.');
+        var xTarget = posX;
+        var yTarget = posY;
+
+        PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
+      case 0: // Boyfriend
+        // Focus the camera on the player.
+        trace('Focusing camera on player.');
+        var xTarget = PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.x + posX;
+        var yTarget = PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.y + posY;
+
+        PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
+      case 1: // Dad
+        // Focus the camera on the dad.
+        trace('Focusing camera on dad.');
+        var xTarget = PlayState.instance.currentStage.getDad().cameraFocusPoint.x + posX;
+        var yTarget = PlayState.instance.currentStage.getDad().cameraFocusPoint.y + posY;
+
+        PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
+      case 2: // Girlfriend
+        // Focus the camera on the girlfriend.
+        trace('Focusing camera on girlfriend.');
+        var xTarget = PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.x + posX;
+        var yTarget = PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.y + posY;
+
+        PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
+      default:
+        trace('Unknown camera focus: ' + data);
+    }
+  }
+
+  public override function getTitle():String
+  {
+    return "Focus Camera";
+  }
+
+  /**
+   * ```
+   * {
+   *   "char": ENUM, // Which character to point to
+   *   "x": FLOAT, // Optional x offset
+   *   "y": FLOAT, // Optional y offset
+   * }
+   * @return SongEventSchema
+   */
+  public override function getEventSchema():SongEventSchema
+  {
+    return [
+      {
+        name: "char",
+        title: "Character",
+        defaultValue: 0,
+        type: SongEventFieldType.ENUM,
+        keys: ["Position" => -1, "Boyfriend" => 0, "Dad" => 1, "Girlfriend" => 2]
+      },
+      {
+        name: "x",
+        title: "X Position",
+        defaultValue: 0,
+        step: 10.0,
+        type: SongEventFieldType.FLOAT,
+      },
+      {
+        name: "y",
+        title: "Y Position",
+        defaultValue: 0,
+        step: 10.0,
+        type: SongEventFieldType.FLOAT,
+      }
+    ];
+  }
+}
diff --git a/source/funkin/play/event/PlayAnimationSongEvent.hx b/source/funkin/play/event/PlayAnimationSongEvent.hx
new file mode 100644
index 000000000..fcf246d57
--- /dev/null
+++ b/source/funkin/play/event/PlayAnimationSongEvent.hx
@@ -0,0 +1,111 @@
+package funkin.play.event;
+
+import flixel.FlxSprite;
+import funkin.play.character.BaseCharacter;
+import funkin.play.event.SongEvent;
+import funkin.play.song.SongData;
+
+class PlayAnimationSongEvent extends SongEvent
+{
+  public function new()
+  {
+    super('PlayAnimation');
+  }
+
+  public override function handleEvent(data:SongEventData)
+  {
+    // Does nothing if there is no PlayState camera or stage.
+    if (PlayState.instance == null || PlayState.instance.currentStage == null)
+      return;
+
+    var targetName = data.getString('target');
+    var anim = data.getString('anim');
+    var force = data.getBool('force');
+    if (force == null)
+      force = false;
+
+    var target:FlxSprite = null;
+
+    switch (targetName)
+    {
+      case 'boyfriend':
+        trace('Playing animation $anim on boyfriend.');
+        target = PlayState.instance.currentStage.getBoyfriend();
+      case 'bf':
+        trace('Playing animation $anim on boyfriend.');
+        target = PlayState.instance.currentStage.getBoyfriend();
+      case 'player':
+        trace('Playing animation $anim on boyfriend.');
+        target = PlayState.instance.currentStage.getBoyfriend();
+      case 'dad':
+        trace('Playing animation $anim on dad.');
+        target = PlayState.instance.currentStage.getDad();
+      case 'opponent':
+        trace('Playing animation $anim on dad.');
+        target = PlayState.instance.currentStage.getDad();
+      case 'girlfriend':
+        trace('Playing animation $anim on girlfriend.');
+        target = PlayState.instance.currentStage.getGirlfriend();
+      case 'gf':
+        trace('Playing animation $anim on girlfriend.');
+        target = PlayState.instance.currentStage.getGirlfriend();
+      default:
+        target = PlayState.instance.currentStage.getNamedProp(targetName);
+        if (target == null)
+          trace('Unknown animation target: $targetName');
+        else
+          trace('Fetched animation target $targetName from stage.');
+    }
+
+    if (target != null)
+    {
+      if (Std.isOfType(target, BaseCharacter))
+      {
+        var targetChar:BaseCharacter = cast target;
+        targetChar.playAnimation(anim, force, force);
+      }
+      else
+      {
+        target.animation.play(anim, force);
+      }
+    }
+  }
+
+  public override function getTitle():String
+  {
+    return "Play Animation";
+  }
+
+  /**
+   * ```
+   * {
+   *   "target": STRING, // Name of character or prop to point to.
+   *   "anim": STRING, // Name of animation to play.
+   *   "force": BOOL, // Whether to force the animation to play.
+   * }
+   * @return SongEventSchema
+   */
+  public override function getEventSchema():SongEventSchema
+  {
+    return [
+      {
+        name: 'target',
+        title: 'Target',
+        type: SongEventFieldType.STRING,
+        defaultValue: 'boyfriend',
+      },
+      {
+        name: 'anim',
+        title: 'Animation',
+        type: SongEventFieldType.STRING,
+        defaultValue: 'idle',
+      },
+      {
+        name: 'force',
+        title: 'Force',
+        type: SongEventFieldType.BOOL,
+        defaultValue: false
+      }
+    ];
+  }
+}
diff --git a/source/funkin/play/event/ScriptedSongEvent.hx b/source/funkin/play/event/ScriptedSongEvent.hx
new file mode 100644
index 000000000..079e35110
--- /dev/null
+++ b/source/funkin/play/event/ScriptedSongEvent.hx
@@ -0,0 +1,9 @@
+package funkin.play.event;
+
+import funkin.play.song.Song;
+import polymod.hscript.HScriptedClass;
+
+@:hscriptClass
+class ScriptedSongEvent extends SongEvent implements HScriptedClass
+{
+}
diff --git a/source/funkin/play/event/SongEvent.hx b/source/funkin/play/event/SongEvent.hx
index 4c0e29575..a06cfb23a 100644
--- a/source/funkin/play/event/SongEvent.hx
+++ b/source/funkin/play/event/SongEvent.hx
@@ -1,303 +1,270 @@
 package funkin.play.event;
 
-import flixel.FlxSprite;
-import funkin.play.PlayState;
-import funkin.play.character.BaseCharacter;
-import funkin.play.song.SongData.RawSongEventData;
-import haxe.DynamicAccess;
+import funkin.util.macro.ClassMacro;
+import funkin.play.song.SongData.SongEventData;
 
-typedef RawSongEvent =
+/**
+ * This class represents a handler for a type of song event.
+ * It is used by the ScriptedSongEvent class to handle user-defined events.
+ */
+class SongEvent
 {
-	> RawSongEventData,
+  public var id:String;
 
-	/**
-	 * Whether the event has been activated or not.
-	 */
-	var a:Bool;
+  public function new(id:String)
+  {
+    this.id = id;
+  }
+
+  public function handleEvent(data:SongEventData)
+  {
+    throw 'SongEvent.handleEvent() must be overridden!';
+  }
+
+  public function getEventSchema():SongEventSchema
+  {
+    return null;
+  }
+
+  public function getTitle():String
+  {
+    return this.id.toTitleCase();
+  }
+
+  public function toString():String
+  {
+    return 'SongEvent(${this.id})';
+  }
 }
 
-@:forward
-abstract SongEvent(RawSongEvent)
+class SongEventParser
 {
-	public function new(time:Float, event:String, value:Dynamic = null)
-	{
-		this = {
-			t: time,
-			e: event,
-			v: value,
-			a: false
-		};
-	}
+  /**
+   * Every built-in event class must be added to this list.
+   * Thankfully, with the power of `SongEventMacro`, this is done automatically.
+   */
+  private static final BUILTIN_EVENTS:List<Class<SongEvent>> = ClassMacro.listSubclassesOf(SongEvent);
 
-	public var time(get, set):Float;
+  /**
+   * Map of internal handlers for song events.
+   * These may be either `ScriptedSongEvents` or built-in classes extending `SongEvent`.
+   */
+  static final eventCache:Map<String, SongEvent> = new Map<String, SongEvent>();
 
-	public function get_time():Float
-	{
-		return this.t;
-	}
+  public static function loadEventCache():Void
+  {
+    clearEventCache();
 
-	public function set_time(value:Float):Float
-	{
-		return this.t = value;
-	}
+    //
+    // BASE GAME EVENTS
+    //
+    registerBaseEvents();
+    registerScriptedEvents();
+  }
 
-	public var event(get, set):String;
+  static function registerBaseEvents()
+  {
+    trace('Instantiating ${BUILTIN_EVENTS.length} built-in song events...');
+    for (eventCls in BUILTIN_EVENTS)
+    {
+      var eventClsName:String = Type.getClassName(eventCls);
+      if (eventClsName == 'funkin.play.event.SongEvent' || eventClsName == 'funkin.play.event.ScriptedSongEvent')
+        continue;
 
-	public function get_event():String
-	{
-		return this.e;
-	}
+      var event:SongEvent = Type.createInstance(eventCls, ["UNKNOWN"]);
 
-	public function set_event(value:String):String
-	{
-		return this.e = value;
-	}
+      if (event != null)
+      {
+        trace('  Loaded built-in song event: (${event.id})');
+        eventCache.set(event.id, event);
+      }
+      else
+      {
+        trace('  Failed to load built-in song event: ${Type.getClassName(eventCls)}');
+      }
+    }
+  }
 
-	public var value(get, set):Dynamic;
+  static function registerScriptedEvents()
+  {
+    var scriptedEventClassNames:Array<String> = ScriptedSongEvent.listScriptClasses();
+    if (scriptedEventClassNames == null || scriptedEventClassNames.length == 0)
+      return;
 
-	public function get_value():Dynamic
-	{
-		return this.v;
-	}
+    trace('Instantiating ${scriptedEventClassNames.length} scripted song events...');
+    for (eventCls in scriptedEventClassNames)
+    {
+      var event:SongEvent = ScriptedSongEvent.init(eventCls, "UKNOWN");
 
-	public function set_value(value:Dynamic):Dynamic
-	{
-		return this.v = value;
-	}
+      if (event != null)
+      {
+        trace('  Loaded scripted song event: ${event.id}');
+        eventCache.set(event.id, event);
+      }
+      else
+      {
+        trace('  Failed to instantiate scripted song event class: ${eventCls}');
+      }
+    }
+  }
 
-	public inline function getBool():Bool
-	{
-		return cast this.v;
-	}
+  public static function listEventIds():Array<String>
+  {
+    return eventCache.keys().array();
+  }
 
-	public inline function getInt():Int
-	{
-		return cast this.v;
-	}
+  public static function listEvents():Array<SongEvent>
+  {
+    return eventCache.values();
+  }
 
-	public inline function getFloat():Float
-	{
-		return cast this.v;
-	}
+  public static function getEvent(id:String):SongEvent
+  {
+    return eventCache.get(id);
+  }
 
-	public inline function getString():String
-	{
-		return cast this.v;
-	}
+  public static function getEventSchema(id:String):SongEventSchema
+  {
+    var event:SongEvent = getEvent(id);
+    if (event == null)
+      return null;
 
-	public inline function getArray():Array<Dynamic>
-	{
-		return cast this.v;
-	}
+    return event.getEventSchema();
+  }
 
-	public inline function getMap():DynamicAccess<Dynamic>
-	{
-		return cast this.v;
-	}
+  static function clearEventCache()
+  {
+    eventCache.clear();
+  }
 
-	public inline function getBoolArray():Array<Bool>
-	{
-		return cast this.v;
-	}
+  public static function handleEvent(data:SongEventData):Void
+  {
+    var eventType:String = data.event;
+    var eventHandler:SongEvent = eventCache.get(eventType);
+
+    if (eventHandler != null)
+    {
+      eventHandler.handleEvent(data);
+    }
+    else
+    {
+      trace('WARNING: No event handler for event with id: ${eventType}');
+    }
+
+    data.activated = true;
+  }
+
+  public static inline function handleEvents(events:Array<SongEventData>):Void
+  {
+    for (event in events)
+    {
+      handleEvent(event);
+    }
+  }
+
+  /**
+   * Given a list of song events and the current timestamp,
+   * return a list of events that should be handled.
+   */
+  public static function queryEvents(events:Array<SongEventData>, currentTime:Float):Array<SongEventData>
+  {
+    return events.filter(function(event:SongEventData):Bool
+    {
+      // If the event is already activated, don't activate it again.
+      if (event.activated)
+        return false;
+
+      // If the event is in the future, don't activate it.
+      if (event.time > currentTime)
+        return false;
+
+      return true;
+    });
+  }
+
+  /**
+   * Reset activation of all the provided events.
+   */
+  public static function resetEvents(events:Array<SongEventData>):Void
+  {
+    for (event in events)
+    {
+      event.activated = false;
+      // TODO: Add an onReset() method to SongEvent?
+    }
+  }
 }
 
-typedef SongEventCallback = SongEvent->Void;
-
-class SongEventHandler
+enum abstract SongEventFieldType(String) from String to String
 {
-	private static final eventCallbacks:Map<String, SongEventCallback> = new Map<String, SongEventCallback>();
+  /**
+   * The STRING type will display as a text field.
+   */
+  var STRING = "string";
 
-	public static function registerCallback(event:String, callback:SongEventCallback):Void
-	{
-		eventCallbacks.set(event, callback);
-	}
+  /**
+   * The INTEGER type will display as a text field that only accepts numbers.
+   */
+  var INTEGER = "integer";
 
-	public static function unregisterCallback(event:String):Void
-	{
-		eventCallbacks.remove(event);
-	}
+  /**
+   * The FLOAT type will display as a text field that only accepts numbers.
+   */
+  var FLOAT = "float";
 
-	public static function clearCallbacks():Void
-	{
-		eventCallbacks.clear();
-	}
+  /**
+   * The BOOL type will display as a checkbox.
+   */
+  var BOOL = "bool";
 
-	/**
-	 * Register each of the event callbacks provided by the base game.
-	 */
-	public static function registerBaseEventCallbacks():Void
-	{
-		// TODO: Add a system for mods to easily add their own event callbacks.
-		// Should be easy as creating character or stage scripts.
-		registerCallback('FocusCamera', VanillaEventCallbacks.focusCamera);
-		registerCallback('PlayAnimation', VanillaEventCallbacks.playAnimation);
-	}
-
-	/**
-	 * Given a list of song events and the current timestamp,
-	 * return a list of events that should be activated.
-	 */
-	public static function queryEvents(events:Array<SongEvent>, currentTime:Float):Array<SongEvent>
-	{
-		return events.filter(function(event:SongEvent):Bool
-		{
-			// If the event is already activated, don't activate it again.
-			if (event.a)
-				return false;
-
-			// If the event is in the future, don't activate it.
-			if (event.time > currentTime)
-				return false;
-
-			return true;
-		});
-	}
-
-	public static function activateEvents(events:Array<SongEvent>):Void
-	{
-		for (event in events)
-		{
-			activateEvent(event);
-		}
-	}
-
-	public static function activateEvent(event:SongEvent):Void
-	{
-		if (event.a)
-		{
-			trace('Event already activated: ' + event);
-			return;
-		}
-
-		// Prevent the event from being activated again.
-		event.a = true;
-
-		// Perform the action.
-		if (eventCallbacks.exists(event.event))
-		{
-			eventCallbacks.get(event.event)(event);
-		}
-	}
-
-	public static function resetEvents(events:Array<SongEvent>):Void
-	{
-		for (event in events)
-		{
-			resetEvent(event);
-		}
-	}
-
-	public static function resetEvent(event:SongEvent):Void
-	{
-		// TODO: Add a system for mods to easily add their reset callbacks.
-		event.a = false;
-	}
+  /**
+   * The ENUM type will display as a dropdown.
+   * Make sure to specify the `keys` field in the schema.
+   */
+  var ENUM = "enum";
 }
 
-class VanillaEventCallbacks
+typedef SongEventSchemaField =
 {
-	/**
-	 * Event Name: "FocusCamera"
-	 * Event Value: Int
-	 *   0: Focus on the player.
-	 *   1: Focus on the opponent.
-	 *   2: Focus on the girlfriend.
-	 */
-	public static function focusCamera(event:SongEvent):Void
-	{
-		// Does nothing if there is no PlayState camera or stage.
-		if (PlayState.instance == null || PlayState.instance.currentStage == null)
-			return;
+  /**
+   * The name of the property as it should be saved in the event data.
+   */
+  name:String,
 
-		switch (event.getInt())
-		{
-			case 0: // Boyfriend
-				// Focus the camera on the player.
-				trace('[EVENT] Focusing camera on player.');
-				PlayState.instance.cameraFollowPoint.setPosition(PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.x,
-					PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.y);
-			case 1: // Dad
-				// Focus the camera on the dad.
-				trace('[EVENT] Focusing camera on dad.');
-				PlayState.instance.cameraFollowPoint.setPosition(PlayState.instance.currentStage.getDad().cameraFocusPoint.x,
-					PlayState.instance.currentStage.getDad().cameraFocusPoint.y);
-			case 2: // Girlfriend
-				// Focus the camera on the girlfriend.
-				trace('[EVENT] Focusing camera on girlfriend.');
-				PlayState.instance.cameraFollowPoint.setPosition(PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.x,
-					PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.y);
-			default:
-				trace('[EVENT] Unknown camera focus: ' + event.value);
-		}
-	}
+  /**
+   * The title of the field to display in the UI.
+   */
+  title:String,
 
-	/**
-	 * Event Name: "playAnimation"
-	 * Event Value: Object
-	 *   {
-	 *     target: String, // "player", "dad", "girlfriend", or <stage prop id>
-	 * 	   animation: String,
-	 *     force: Bool // optional
-	 *   }
-	 */
-	public static function playAnimation(event:SongEvent):Void
-	{
-		// Does nothing if there is no PlayState camera or stage.
-		if (PlayState.instance == null || PlayState.instance.currentStage == null)
-			return;
+  /**
+   * The type of the field.
+   */
+  type:SongEventFieldType,
 
-		var data:Dynamic = event.value;
-
-		var targetName:String = Reflect.field(data, 'target');
-		var anim:String = Reflect.field(data, 'anim');
-		var force:Null<Bool> = Reflect.field(data, 'force');
-		if (force == null)
-			force = false;
-
-		var target:FlxSprite = null;
-
-		switch (targetName)
-		{
-			case 'boyfriend':
-				trace('[EVENT] Playing animation $anim on boyfriend.');
-				target = PlayState.instance.currentStage.getBoyfriend();
-			case 'bf':
-				trace('[EVENT] Playing animation $anim on boyfriend.');
-				target = PlayState.instance.currentStage.getBoyfriend();
-			case 'player':
-				trace('[EVENT] Playing animation $anim on boyfriend.');
-				target = PlayState.instance.currentStage.getBoyfriend();
-			case 'dad':
-				trace('[EVENT] Playing animation $anim on dad.');
-				target = PlayState.instance.currentStage.getDad();
-			case 'opponent':
-				trace('[EVENT] Playing animation $anim on dad.');
-				target = PlayState.instance.currentStage.getDad();
-			case 'girlfriend':
-				trace('[EVENT] Playing animation $anim on girlfriend.');
-				target = PlayState.instance.currentStage.getGirlfriend();
-			case 'gf':
-				trace('[EVENT] Playing animation $anim on girlfriend.');
-				target = PlayState.instance.currentStage.getGirlfriend();
-			default:
-				target = PlayState.instance.currentStage.getNamedProp(targetName);
-				if (target == null)
-					trace('[EVENT] Unknown animation target: $targetName');
-				else
-					trace('[EVENT] Fetched animation target $targetName from stage.');
-		}
-
-		if (target != null)
-		{
-			if (Std.isOfType(target, BaseCharacter))
-			{
-				var targetChar:BaseCharacter = cast target;
-				targetChar.playAnimation(anim, force, force);
-			}
-			else
-			{
-				target.animation.play(anim, force);
-			}
-		}
-	}
+  /**
+   * Used for ENUM values.
+   * The key is the display name and the value is the actual value.
+   */
+  ?keys:Map<String, Dynamic>,
+  /**
+   * Used for INTEGER and FLOAT values.
+   * The minimum value that can be entered.
+   */
+  ?min:Float,
+  /**
+   * Used for INTEGER and FLOAT values.
+   * The maximum value that can be entered.
+   */
+  ?max:Float,
+  /**
+   * Used for INTEGER and FLOAT values.
+   * The step value that will be used when incrementing/decrementing the value.
+   */
+  ?step:Float,
+  /**
+   * An optional default value for the field.
+   */
+  ?defaultValue:Dynamic,
 }
+
+typedef SongEventSchema = Array<SongEventSchemaField>;
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 08ce6818f..f15f4dafb 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -22,239 +22,239 @@ import funkin.play.song.SongData.SongTimeFormat;
  */
 class Song // implements IPlayStateScriptedClass
 {
-	public final songId:String;
+  public final songId:String;
 
-	final _metadata:Array<SongMetadata>;
+  final _metadata:Array<SongMetadata>;
 
-	final variations:Array<String>;
-	final difficulties:Map<String, SongDifficulty>;
+  final variations:Array<String>;
+  final difficulties:Map<String, SongDifficulty>;
 
-	public function new(id:String)
-	{
-		this.songId = id;
+  public function new(id:String)
+  {
+    this.songId = id;
 
-		variations = [];
-		difficulties = new Map<String, SongDifficulty>();
+    variations = [];
+    difficulties = new Map<String, SongDifficulty>();
 
-		_metadata = SongDataParser.parseSongMetadata(songId);
-		if (_metadata == null || _metadata.length == 0)
-		{
-			throw 'Could not find song data for songId: $songId';
-		}
+    _metadata = SongDataParser.parseSongMetadata(songId);
+    if (_metadata == null || _metadata.length == 0)
+    {
+      throw 'Could not find song data for songId: $songId';
+    }
 
-		populateFromMetadata();
-	}
+    populateFromMetadata();
+  }
 
-	public function getRawMetadata():Array<SongMetadata>
-	{
-		return _metadata;
-	}
+  public function getRawMetadata():Array<SongMetadata>
+  {
+    return _metadata;
+  }
 
-	/**
-	 * Populate the song data from the provided metadata,
-	 * including data from individual difficulties. Does not load chart data.
-	 */
-	function populateFromMetadata():Void
-	{
-		// Variations may have different artist, time format, generatedBy, etc.
-		for (metadata in _metadata)
-		{
-			for (diffId in metadata.playData.difficulties)
-			{
-				var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation);
+  /**
+   * Populate the song data from the provided metadata,
+   * including data from individual difficulties. Does not load chart data.
+   */
+  function populateFromMetadata():Void
+  {
+    // Variations may have different artist, time format, generatedBy, etc.
+    for (metadata in _metadata)
+    {
+      for (diffId in metadata.playData.difficulties)
+      {
+        var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation);
 
-				variations.push(metadata.variation);
+        variations.push(metadata.variation);
 
-				difficulty.songName = metadata.songName;
-				difficulty.songArtist = metadata.artist;
-				difficulty.timeFormat = metadata.timeFormat;
-				difficulty.divisions = metadata.divisions;
-				difficulty.timeChanges = metadata.timeChanges;
-				difficulty.loop = metadata.loop;
-				difficulty.generatedBy = metadata.generatedBy;
+        difficulty.songName = metadata.songName;
+        difficulty.songArtist = metadata.artist;
+        difficulty.timeFormat = metadata.timeFormat;
+        difficulty.divisions = metadata.divisions;
+        difficulty.timeChanges = metadata.timeChanges;
+        difficulty.loop = metadata.loop;
+        difficulty.generatedBy = metadata.generatedBy;
 
-				difficulty.stage = metadata.playData.stage;
-				// difficulty.noteSkin = metadata.playData.noteSkin;
+        difficulty.stage = metadata.playData.stage;
+        // difficulty.noteSkin = metadata.playData.noteSkin;
 
-				difficulty.chars = new Map<String, SongPlayableChar>();
-				for (charId in metadata.playData.playableChars.keys())
-				{
-					var char = metadata.playData.playableChars.get(charId);
+        difficulty.chars = new Map<String, SongPlayableChar>();
+        for (charId in metadata.playData.playableChars.keys())
+        {
+          var char = metadata.playData.playableChars.get(charId);
 
-					difficulty.chars.set(charId, char);
-				}
+          difficulty.chars.set(charId, char);
+        }
 
-				difficulties.set(diffId, difficulty);
-			}
-		}
-	}
+        difficulties.set(diffId, difficulty);
+      }
+    }
+  }
 
-	/**
-	 * Parse and cache the chart for all difficulties of this song.
-	 */
-	public function cacheCharts(?force:Bool = false):Void
-	{
-		if (force)
-		{
-			clearCharts();
-		}
+  /**
+   * Parse and cache the chart for all difficulties of this song.
+   */
+  public function cacheCharts(?force:Bool = false):Void
+  {
+    if (force)
+    {
+      clearCharts();
+    }
 
-		trace('Caching ${variations.length} chart files for song $songId');
-		for (variation in variations)
-		{
-			var chartData:SongChartData = SongDataParser.parseSongChartData(songId, variation);
-			var chartNotes = chartData.notes;
+    trace('Caching ${variations.length} chart files for song $songId');
+    for (variation in variations)
+    {
+      var chartData:SongChartData = SongDataParser.parseSongChartData(songId, variation);
+      var chartNotes = chartData.notes;
 
-			for (diffId in chartNotes.keys())
-			{
-				// Retrieve the cached difficulty data.
-				var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
-				if (difficulty == null)
-				{
-					trace('Could not find difficulty $diffId for song $songId');
-					continue;
-				}
-				// Add the chart data to the difficulty.
-				difficulty.notes = chartData.notes.get(diffId);
-				difficulty.scrollSpeed = chartData.getScrollSpeed(diffId);
+      for (diffId in chartNotes.keys())
+      {
+        // Retrieve the cached difficulty data.
+        var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
+        if (difficulty == null)
+        {
+          trace('Could not find difficulty $diffId for song $songId');
+          continue;
+        }
+        // Add the chart data to the difficulty.
+        difficulty.notes = chartData.notes.get(diffId);
+        difficulty.scrollSpeed = chartData.getScrollSpeed(diffId);
 
-				difficulty.events = chartData.events;
-			}
-		}
-		trace('Done caching charts.');
-	}
+        difficulty.events = chartData.events;
+      }
+    }
+    trace('Done caching charts.');
+  }
 
-	/**
-	 * Retrieve the metadata for a specific difficulty, including the chart if it is loaded.
-	 */
-	public inline function getDifficulty(?diffId:String):SongDifficulty
-	{
-		if (diffId == null)
-			diffId = difficulties.keys().array()[0];
+  /**
+   * Retrieve the metadata for a specific difficulty, including the chart if it is loaded.
+   */
+  public inline function getDifficulty(?diffId:String):SongDifficulty
+  {
+    if (diffId == null)
+      diffId = difficulties.keys().array()[0];
 
-		return difficulties.get(diffId);
-	}
+    return difficulties.get(diffId);
+  }
 
-	/**
-	 * Purge the cached chart data for each difficulty of this song.
-	 */
-	public function clearCharts():Void
-	{
-		for (diff in difficulties)
-		{
-			diff.clearChart();
-		}
-	}
+  /**
+   * Purge the cached chart data for each difficulty of this song.
+   */
+  public function clearCharts():Void
+  {
+    for (diff in difficulties)
+    {
+      diff.clearChart();
+    }
+  }
 
-	public function toString():String
-	{
-		return 'Song($songId)';
-	}
+  public function toString():String
+  {
+    return 'Song($songId)';
+  }
 }
 
 class SongDifficulty
 {
-	/**
-	 * The parent song for this difficulty.
-	 */
-	public final song:Song;
+  /**
+   * The parent song for this difficulty.
+   */
+  public final song:Song;
 
-	/**
-	 * The difficulty ID, such as `easy` or `hard`.
-	 */
-	public final difficulty:String;
+  /**
+   * The difficulty ID, such as `easy` or `hard`.
+   */
+  public final difficulty:String;
 
-	/**
-	 * The metadata file that contains this difficulty.
-	 */
-	public final variation:String;
+  /**
+   * The metadata file that contains this difficulty.
+   */
+  public final variation:String;
 
-	/**
-	 * The note chart for this difficulty.
-	 */
-	public var notes:Array<SongNoteData>;
+  /**
+   * The note chart for this difficulty.
+   */
+  public var notes:Array<SongNoteData>;
 
-	/**
-	 * The event chart for this difficulty.
-	 */
-	public var events:Array<SongEventData>;
+  /**
+   * The event chart for this difficulty.
+   */
+  public var events:Array<SongEventData>;
 
-	public var songName:String = SongValidator.DEFAULT_SONGNAME;
-	public var songArtist:String = SongValidator.DEFAULT_ARTIST;
-	public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT;
-	public var divisions:Int = SongValidator.DEFAULT_DIVISIONS;
-	public var loop:Bool = SongValidator.DEFAULT_LOOP;
-	public var generatedBy:String = SongValidator.DEFAULT_GENERATEDBY;
+  public var songName:String = SongValidator.DEFAULT_SONGNAME;
+  public var songArtist:String = SongValidator.DEFAULT_ARTIST;
+  public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT;
+  public var divisions:Int = SongValidator.DEFAULT_DIVISIONS;
+  public var loop:Bool = SongValidator.DEFAULT_LOOP;
+  public var generatedBy:String = SongValidator.DEFAULT_GENERATEDBY;
 
-	public var timeChanges:Array<SongTimeChange> = [];
+  public var timeChanges:Array<SongTimeChange> = [];
 
-	public var stage:String = SongValidator.DEFAULT_STAGE;
-	public var chars:Map<String, SongPlayableChar> = null;
+  public var stage:String = SongValidator.DEFAULT_STAGE;
+  public var chars:Map<String, SongPlayableChar> = null;
 
-	public var scrollSpeed:Float = SongValidator.DEFAULT_SCROLLSPEED;
+  public var scrollSpeed:Float = SongValidator.DEFAULT_SCROLLSPEED;
 
-	public function new(song:Song, diffId:String, variation:String)
-	{
-		this.song = song;
-		this.difficulty = diffId;
-		this.variation = variation;
-	}
+  public function new(song:Song, diffId:String, variation:String)
+  {
+    this.song = song;
+    this.difficulty = diffId;
+    this.variation = variation;
+  }
 
-	public function clearChart():Void
-	{
-		notes = null;
-	}
+  public function clearChart():Void
+  {
+    notes = null;
+  }
 
-	public function getStartingBPM():Float
-	{
-		if (timeChanges.length == 0)
-		{
-			return 0;
-		}
+  public function getStartingBPM():Float
+  {
+    if (timeChanges.length == 0)
+    {
+      return 0;
+    }
 
-		return timeChanges[0].bpm;
-	}
+    return timeChanges[0].bpm;
+  }
 
-	public function getPlayableChar(id:String):SongPlayableChar
-	{
-		return chars.get(id);
-	}
+  public function getPlayableChar(id:String):SongPlayableChar
+  {
+    return chars.get(id);
+  }
 
-	public function getPlayableChars():Array<String>
-	{
-		return chars.keys().array();
-	}
+  public function getPlayableChars():Array<String>
+  {
+    return chars.keys().array();
+  }
 
-	public function getEvents():Array<SongEvent>
-	{
-		return cast events;
-	}
+  public function getEvents():Array<SongEventData>
+  {
+    return cast events;
+  }
 
-	public inline function cacheInst()
-	{
-		FlxG.sound.cache(Paths.inst(this.song.songId));
-	}
+  public inline function cacheInst()
+  {
+    FlxG.sound.cache(Paths.inst(this.song.songId));
+  }
 
-	public inline function playInst(volume:Float = 1.0, looped:Bool = false)
-	{
-		FlxG.sound.playMusic(Paths.inst(this.song.songId), volume, looped);
-	}
+  public inline function playInst(volume:Float = 1.0, looped:Bool = false)
+  {
+    FlxG.sound.playMusic(Paths.inst(this.song.songId), volume, looped);
+  }
 
-	public inline function cacheVocals()
-	{
-		FlxG.sound.cache(Paths.voices(this.song.songId));
-	}
+  public inline function cacheVocals()
+  {
+    FlxG.sound.cache(Paths.voices(this.song.songId));
+  }
 
-	public function buildVoiceList():Array<String>
-	{
-		// TODO: Implement.
+  public function buildVoiceList():Array<String>
+  {
+    // TODO: Implement.
 
-		return [""];
-	}
+    return [""];
+  }
 
-	public function buildVocals(charId:String = "bf"):VoicesGroup
-	{
-		var result:VoicesGroup = VoicesGroup.build(this.song.songId, this.buildVoiceList());
-		return result;
-	}
+  public function buildVocals(charId:String = "bf"):VoicesGroup
+  {
+    var result:VoicesGroup = VoicesGroup.build(this.song.songId, this.buildVoiceList());
+    return result;
+  }
 }
diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx
index 775e78c11..480c3aab5 100644
--- a/source/funkin/play/song/SongData.hx
+++ b/source/funkin/play/song/SongData.hx
@@ -13,743 +13,771 @@ import thx.semver.Version;
  */
 class SongDataParser
 {
-	/**
-	 * A list containing all the songs available to the game.
-	 */
-	static final songCache:Map<String, Song> = new Map<String, Song>();
+  /**
+   * A list containing all the songs available to the game.
+   */
+  static final songCache:Map<String, Song> = new Map<String, Song>();
 
-	static final DEFAULT_SONG_ID = 'UNKNOWN';
-	static final SONG_DATA_PATH = 'songs/';
-	static final SONG_DATA_SUFFIX = '-metadata.json';
+  static final DEFAULT_SONG_ID = 'UNKNOWN';
+  static final SONG_DATA_PATH = 'songs/';
+  static final SONG_DATA_SUFFIX = '-metadata.json';
 
-	/**
-	 * Parses and preloads the game's song metadata and scripts when the game starts.
-	 * 
-	 * If you want to force song metadata to be reloaded, you can just call this function again.
-	 */
-	public static function loadSongCache():Void
-	{
-		clearSongCache();
-		trace("[SONGDATA] Loading song cache...");
+  /**
+   * Parses and preloads the game's song metadata and scripts when the game starts.
+   * 
+   * If you want to force song metadata to be reloaded, you can just call this function again.
+   */
+  public static function loadSongCache():Void
+  {
+    clearSongCache();
+    trace("Loading song cache...");
 
-		//
-		// SCRIPTED SONGS
-		//
-		var scriptedSongClassNames:Array<String> = ScriptedSong.listScriptClasses();
-		trace('  Instantiating ${scriptedSongClassNames.length} scripted songs...');
-		for (songCls in scriptedSongClassNames)
-		{
-			var song:Song = ScriptedSong.init(songCls, DEFAULT_SONG_ID);
-			if (song != null)
-			{
-				trace('    Loaded scripted song: ${song.songId}');
-				songCache.set(song.songId, song);
-			}
-			else
-			{
-				trace('    Failed to instantiate scripted song class: ${songCls}');
-			}
-		}
+    //
+    // SCRIPTED SONGS
+    //
+    var scriptedSongClassNames:Array<String> = ScriptedSong.listScriptClasses();
+    trace('  Instantiating ${scriptedSongClassNames.length} scripted songs...');
+    for (songCls in scriptedSongClassNames)
+    {
+      var song:Song = ScriptedSong.init(songCls, DEFAULT_SONG_ID);
+      if (song != null)
+      {
+        trace('    Loaded scripted song: ${song.songId}');
+        songCache.set(song.songId, song);
+      }
+      else
+      {
+        trace('    Failed to instantiate scripted song class: ${songCls}');
+      }
+    }
 
-		//
-		// UNSCRIPTED SONGS
-		//
-		var songIdList:Array<String> = DataAssets.listDataFilesInPath(SONG_DATA_PATH, SONG_DATA_SUFFIX).map(function(songDataPath:String):String
-		{
-			return songDataPath.split('/')[0];
-		});
-		var unscriptedSongIds:Array<String> = songIdList.filter(function(songId:String):Bool
-		{
-			return !songCache.exists(songId);
-		});
-		trace('  Instantiating ${unscriptedSongIds.length} non-scripted songs...');
-		for (songId in unscriptedSongIds)
-		{
-			try
-			{
-				var song = new Song(songId);
-				if (song != null)
-				{
-					trace('    Loaded song data: ${song.songId}');
-					songCache.set(song.songId, song);
-				}
-			}
-			catch (e)
-			{
-				trace('    An error occurred while loading song data: ${songId}');
-				trace(e);
-				// Assume error was already logged.
-				continue;
-			}
-		}
+    //
+    // UNSCRIPTED SONGS
+    //
+    var songIdList:Array<String> = DataAssets.listDataFilesInPath(SONG_DATA_PATH, SONG_DATA_SUFFIX).map(function(songDataPath:String):String
+    {
+      return songDataPath.split('/')[0];
+    });
+    var unscriptedSongIds:Array<String> = songIdList.filter(function(songId:String):Bool
+    {
+      return !songCache.exists(songId);
+    });
+    trace('  Instantiating ${unscriptedSongIds.length} non-scripted songs...');
+    for (songId in unscriptedSongIds)
+    {
+      try
+      {
+        var song = new Song(songId);
+        if (song != null)
+        {
+          trace('    Loaded song data: ${song.songId}');
+          songCache.set(song.songId, song);
+        }
+      }
+      catch (e)
+      {
+        trace('    An error occurred while loading song data: ${songId}');
+        trace(e);
+        // Assume error was already logged.
+        continue;
+      }
+    }
 
-		trace('  Successfully loaded ${Lambda.count(songCache)} stages.');
-	}
+    trace('  Successfully loaded ${Lambda.count(songCache)} stages.');
+  }
 
-	/**
-	 * Retrieves a particular song from the cache.
-	 */
-	public static function fetchSong(songId:String):Null<Song>
-	{
-		if (songCache.exists(songId))
-		{
-			var song:Song = songCache.get(songId);
-			trace('[SONGDATA] Successfully fetch song: ${songId}');
-			return song;
-		}
-		else
-		{
-			trace('[SONGDATA] Failed to fetch song, not found in cache: ${songId}');
-			return null;
-		}
-	}
+  /**
+   * Retrieves a particular song from the cache.
+   */
+  public static function fetchSong(songId:String):Null<Song>
+  {
+    if (songCache.exists(songId))
+    {
+      var song:Song = songCache.get(songId);
+      trace('Successfully fetch song: ${songId}');
+      return song;
+    }
+    else
+    {
+      trace('Failed to fetch song, not found in cache: ${songId}');
+      return null;
+    }
+  }
 
-	static function clearSongCache():Void
-	{
-		if (songCache != null)
-		{
-			songCache.clear();
-		}
-	}
+  static function clearSongCache():Void
+  {
+    if (songCache != null)
+    {
+      songCache.clear();
+    }
+  }
 
-	public static function listSongIds():Array<String>
-	{
-		return songCache.keys().array();
-	}
+  public static function listSongIds():Array<String>
+  {
+    return songCache.keys().array();
+  }
 
-	public static function parseSongMetadata(songId:String):Array<SongMetadata>
-	{
-		var result:Array<SongMetadata> = [];
+  public static function parseSongMetadata(songId:String):Array<SongMetadata>
+  {
+    var result:Array<SongMetadata> = [];
 
-		var rawJson:String = loadSongMetadataFile(songId);
-		var jsonData:Dynamic = null;
-		try
-		{
-			jsonData = Json.parse(rawJson);
-		}
-		catch (e)
-		{
-		}
+    var rawJson:String = loadSongMetadataFile(songId);
+    var jsonData:Dynamic = null;
+    try
+    {
+      jsonData = Json.parse(rawJson);
+    }
+    catch (e) {}
 
-		var songMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, songId);
-		songMetadata = SongValidator.validateSongMetadata(songMetadata, songId);
+    var songMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, songId);
+    songMetadata = SongValidator.validateSongMetadata(songMetadata, songId);
 
-		if (songMetadata == null)
-		{
-			return result;
-		}
+    if (songMetadata == null)
+    {
+      return result;
+    }
 
-		result.push(songMetadata);
+    result.push(songMetadata);
 
-		var variations = songMetadata.playData.songVariations;
+    var variations = songMetadata.playData.songVariations;
 
-		for (variation in variations)
-		{
-			var variationRawJson:String = loadSongMetadataFile(songId, variation);
-			var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationRawJson, '${songId}_${variation}');
-			variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}_${variation}');
-			if (variationSongMetadata != null)
-			{
-				variationSongMetadata.variation = variation;
-				result.push(variationSongMetadata);
-			}
-		}
+    for (variation in variations)
+    {
+      var variationRawJson:String = loadSongMetadataFile(songId, variation);
+      var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationRawJson, '${songId}_${variation}');
+      variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}_${variation}');
+      if (variationSongMetadata != null)
+      {
+        variationSongMetadata.variation = variation;
+        result.push(variationSongMetadata);
+      }
+    }
 
-		return result;
-	}
+    return result;
+  }
 
-	static function loadSongMetadataFile(songPath:String, variation:String = ''):String
-	{
-		var songMetadataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata');
+  static function loadSongMetadataFile(songPath:String, variation:String = ''):String
+  {
+    var songMetadataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata');
 
-		var rawJson:String = Assets.getText(songMetadataFilePath).trim();
+    var rawJson:String = Assets.getText(songMetadataFilePath).trim();
 
-		while (!rawJson.endsWith("}"))
-		{
-			rawJson = rawJson.substr(0, rawJson.length - 1);
-		}
+    while (!rawJson.endsWith("}"))
+    {
+      rawJson = rawJson.substr(0, rawJson.length - 1);
+    }
 
-		return rawJson;
-	}
+    return rawJson;
+  }
 
-	public static function parseSongChartData(songId:String, variation:String = ""):SongChartData
-	{
-		var rawJson:String = loadSongChartDataFile(songId, variation);
-		var jsonData:Dynamic = null;
-		try
-		{
-			jsonData = Json.parse(rawJson);
-		}
-		catch (e)
-		{
-		}
+  public static function parseSongChartData(songId:String, variation:String = ""):SongChartData
+  {
+    var rawJson:String = loadSongChartDataFile(songId, variation);
+    var jsonData:Dynamic = null;
+    try
+    {
+      jsonData = Json.parse(rawJson);
+    }
+    catch (e) {}
 
-		var songChartData:SongChartData = SongMigrator.migrateSongChartData(jsonData, songId);
-		songChartData = SongValidator.validateSongChartData(songChartData, songId);
+    var songChartData:SongChartData = SongMigrator.migrateSongChartData(jsonData, songId);
+    songChartData = SongValidator.validateSongChartData(songChartData, songId);
 
-		if (songChartData == null)
-		{
-			trace('Failed to validate song chart data: ${songId}');
-			return null;
-		}
+    if (songChartData == null)
+    {
+      trace('Failed to validate song chart data: ${songId}');
+      return null;
+    }
 
-		return songChartData;
-	}
+    return songChartData;
+  }
 
-	static function loadSongChartDataFile(songPath:String, variation:String = ''):String
-	{
-		var songChartDataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart');
+  static function loadSongChartDataFile(songPath:String, variation:String = ''):String
+  {
+    var songChartDataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart');
 
-		var rawJson:String = Assets.getText(songChartDataFilePath).trim();
+    var rawJson:String = Assets.getText(songChartDataFilePath).trim();
 
-		while (!rawJson.endsWith("}"))
-		{
-			rawJson = rawJson.substr(0, rawJson.length - 1);
-		}
+    while (!rawJson.endsWith("}"))
+    {
+      rawJson = rawJson.substr(0, rawJson.length - 1);
+    }
 
-		return rawJson;
-	}
+    return rawJson;
+  }
 }
 
 typedef RawSongMetadata =
 {
-	/**
-	 * A semantic versioning string for the song data format.
-	 * 
-	 */
-	var version:Version;
+  /**
+   * A semantic versioning string for the song data format.
+   * 
+   */
+  var version:Version;
 
-	var songName:String;
-	var artist:String;
-	var timeFormat:SongTimeFormat;
-	var divisions:Int;
-	var timeChanges:Array<SongTimeChange>;
-	var loop:Bool;
-	var playData:SongPlayData;
-	var generatedBy:String;
+  var songName:String;
+  var artist:String;
+  var timeFormat:SongTimeFormat;
+  var divisions:Int;
+  var timeChanges:Array<SongTimeChange>;
+  var loop:Bool;
+  var playData:SongPlayData;
+  var generatedBy:String;
 
-	/**
-	 * Defaults to `default` or `''`. Populated later.
-	 */
-	var variation:String;
+  /**
+   * Defaults to `default` or `''`. Populated later.
+   */
+  var variation:String;
 };
 
 @:forward
 abstract SongMetadata(RawSongMetadata)
 {
-	public function new(songName:String, artist:String, variation:String = 'default')
-	{
-		this = {
-			version: SongMigrator.CHART_VERSION,
-			songName: songName,
-			artist: artist,
-			timeFormat: 'ms',
-			divisions: 96,
-			timeChanges: [new SongTimeChange(-1, 0, 100, 4, 4, [4, 4, 4, 4])],
-			loop: false,
-			playData: {
-				songVariations: [],
-				difficulties: ['normal'],
+  public function new(songName:String, artist:String, variation:String = 'default')
+  {
+    this = {
+      version: SongMigrator.CHART_VERSION,
+      songName: songName,
+      artist: artist,
+      timeFormat: 'ms',
+      divisions: 96,
+      timeChanges: [new SongTimeChange(-1, 0, 100, 4, 4, [4, 4, 4, 4])],
+      loop: false,
+      playData: {
+        songVariations: [],
+        difficulties: ['normal'],
 
-				playableChars: {
-					bf: new SongPlayableChar('gf', 'dad'),
-				},
+        playableChars: {
+          bf: new SongPlayableChar('gf', 'dad'),
+        },
 
-				stage: 'mainStage',
-				noteSkin: 'Normal'
-			},
-			generatedBy: SongValidator.DEFAULT_GENERATEDBY,
+        stage: 'mainStage',
+        noteSkin: 'Normal'
+      },
+      generatedBy: SongValidator.DEFAULT_GENERATEDBY,
 
-			// Variation ID.
-			variation: variation
-		};
-	}
+      // Variation ID.
+      variation: variation
+    };
+  }
 
-	public function clone(?newVariation:String = null):SongMetadata
-	{
-		var result = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation);
-		result.version = this.version;
-		result.timeFormat = this.timeFormat;
-		result.divisions = this.divisions;
-		result.timeChanges = this.timeChanges;
-		result.loop = this.loop;
-		result.playData = this.playData;
-		result.generatedBy = this.generatedBy;
+  public function clone(?newVariation:String = null):SongMetadata
+  {
+    var result = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation);
+    result.version = this.version;
+    result.timeFormat = this.timeFormat;
+    result.divisions = this.divisions;
+    result.timeChanges = this.timeChanges;
+    result.loop = this.loop;
+    result.playData = this.playData;
+    result.generatedBy = this.generatedBy;
 
-		return result;
-	}
+    return result;
+  }
 }
 
 typedef SongPlayData =
 {
-	var songVariations:Array<String>;
-	var difficulties:Array<String>;
+  var songVariations:Array<String>;
+  var difficulties:Array<String>;
 
-	/**
-	 * Keys are the player characters and the values give info on what opponent/GF/inst to use.
-	 */
-	var playableChars:DynamicAccess<SongPlayableChar>;
+  /**
+   * Keys are the player characters and the values give info on what opponent/GF/inst to use.
+   */
+  var playableChars:DynamicAccess<SongPlayableChar>;
 
-	var stage:String;
-	var noteSkin:String;
+  var stage:String;
+  var noteSkin:String;
 }
 
 typedef RawSongPlayableChar =
 {
-	var g:String;
-	var o:String;
-	var i:String;
+  var g:String;
+  var o:String;
+  var i:String;
 }
 
 typedef RawSongNoteData =
 {
-	/**
-	 * The timestamp of the note. The timestamp is in the format of the song's time format.
-	 */
-	var t:Float;
+  /**
+   * The timestamp of the note. The timestamp is in the format of the song's time format.
+   */
+  var t:Float;
 
-	/**
-	 * Data for the note. Represents the index on the strumline.
-	 * 0 = left, 1 = down, 2 = up, 3 = right
-	 * `floor(direction / strumlineSize)` specifies which strumline the note is on.
-	 * 0 = player, 1 = opponent, etc.
-	 */
-	var d:Int;
+  /**
+   * Data for the note. Represents the index on the strumline.
+   * 0 = left, 1 = down, 2 = up, 3 = right
+   * `floor(direction / strumlineSize)` specifies which strumline the note is on.
+   * 0 = player, 1 = opponent, etc.
+   */
+  var d:Int;
 
-	/**
-	 * Length of the note, if applicable.
-	 * Defaults to 0 for single notes.
-	 */
-	var l:Float;
+  /**
+   * Length of the note, if applicable.
+   * Defaults to 0 for single notes.
+   */
+  var l:Float;
 
-	/**
-	 * The kind of the note.
-	 * This can allow the note to include information used for custom behavior.
-	 * Defaults to blank or `"normal"`.
-	 */
-	var k:String;
+  /**
+   * The kind of the note.
+   * This can allow the note to include information used for custom behavior.
+   * Defaults to blank or `"normal"`.
+   */
+  var k:String;
 }
 
 abstract SongNoteData(RawSongNoteData)
 {
-	public function new(time:Float, data:Int, length:Float = 0, kind:String = "")
-	{
-		this = {
-			t: time,
-			d: data,
-			l: length,
-			k: kind
-		};
-	}
+  public function new(time:Float, data:Int, length:Float = 0, kind:String = "")
+  {
+    this = {
+      t: time,
+      d: data,
+      l: length,
+      k: kind
+    };
+  }
 
-	public var time(get, set):Float;
+  public var time(get, set):Float;
 
-	public function get_time():Float
-	{
-		return this.t;
-	}
+  public function get_time():Float
+  {
+    return this.t;
+  }
 
-	public function set_time(value:Float):Float
-	{
-		return this.t = value;
-	}
+  public function set_time(value:Float):Float
+  {
+    return this.t = value;
+  }
 
-	public var stepTime(get, never):Float;
+  public var stepTime(get, never):Float;
 
-	public function get_stepTime():Float
-	{
-		// TODO: Account for changes in BPM.
-		return this.t / Conductor.stepCrochet;
-	}
+  public function get_stepTime():Float
+  {
+    // TODO: Account for changes in BPM.
+    return this.t / Conductor.stepCrochet;
+  }
 
-	/**
-	 * The raw data for the note.
-	 */
-	public var data(get, set):Int;
+  /**
+   * The raw data for the note.
+   */
+  public var data(get, set):Int;
 
-	public function get_data():Int
-	{
-		return this.d;
-	}
+  public function get_data():Int
+  {
+    return this.d;
+  }
 
-	public function set_data(value:Int):Int
-	{
-		return this.d = value;
-	}
+  public function set_data(value:Int):Int
+  {
+    return this.d = value;
+  }
 
-	/**
-	 * The direction of the note, if applicable.
-	 * Strips the strumline index from the data.
-	 *
-	 * 0 = left, 1 = down, 2 = up, 3 = right
-	 */
-	public inline function getDirection(strumlineSize:Int = 4):Int
-	{
-		return this.d % strumlineSize;
-	}
+  /**
+   * The direction of the note, if applicable.
+   * Strips the strumline index from the data.
+   *
+   * 0 = left, 1 = down, 2 = up, 3 = right
+   */
+  public inline function getDirection(strumlineSize:Int = 4):Int
+  {
+    return this.d % strumlineSize;
+  }
 
-	public function getDirectionName(strumlineSize:Int = 4):String
-	{
-		switch (this.d % strumlineSize)
-		{
-			case 0:
-				return 'Left';
-			case 1:
-				return 'Down';
-			case 2:
-				return 'Up';
-			case 3:
-				return 'Right';
-			default:
-				return 'Unknown';
-		}
-	}
+  public function getDirectionName(strumlineSize:Int = 4):String
+  {
+    switch (this.d % strumlineSize)
+    {
+      case 0:
+        return 'Left';
+      case 1:
+        return 'Down';
+      case 2:
+        return 'Up';
+      case 3:
+        return 'Right';
+      default:
+        return 'Unknown';
+    }
+  }
 
-	/**
-	 * The strumline index of the note, if applicable.
-	 * Strips the direction from the data.
-	 * 
-	 * 0 = player, 1 = opponent, etc.
-	 */
-	public inline function getStrumlineIndex(strumlineSize:Int = 4):Int
-	{
-		return Math.floor(this.d / strumlineSize);
-	}
+  /**
+   * The strumline index of the note, if applicable.
+   * Strips the direction from the data.
+   * 
+   * 0 = player, 1 = opponent, etc.
+   */
+  public inline function getStrumlineIndex(strumlineSize:Int = 4):Int
+  {
+    return Math.floor(this.d / strumlineSize);
+  }
 
-	public inline function getMustHitNote(strumlineSize:Int = 4):Bool
-	{
-		return getStrumlineIndex(strumlineSize) == 0;
-	}
+  public inline function getMustHitNote(strumlineSize:Int = 4):Bool
+  {
+    return getStrumlineIndex(strumlineSize) == 0;
+  }
 
-	public var length(get, set):Float;
+  public var length(get, set):Float;
 
-	public function get_length():Float
-	{
-		return this.l;
-	}
+  public function get_length():Float
+  {
+    return this.l;
+  }
 
-	public function set_length(value:Float):Float
-	{
-		return this.l = value;
-	}
+  public function set_length(value:Float):Float
+  {
+    return this.l = value;
+  }
 
-	public var kind(get, set):String;
+  public var kind(get, set):String;
 
-	public function get_kind():String
-	{
-		if (this.k == null || this.k == '')
-			return 'normal';
+  public function get_kind():String
+  {
+    if (this.k == null || this.k == '')
+      return 'normal';
 
-		return this.k;
-	}
+    return this.k;
+  }
 
-	public function set_kind(value:String):String
-	{
-		if (value == 'normal' || value == '')
-			value = null;
-		return this.k = value;
-	}
+  public function set_kind(value:String):String
+  {
+    if (value == 'normal' || value == '')
+      value = null;
+    return this.k = value;
+  }
 
-	@:op(A == B)
-	public function op_equals(other:SongNoteData):Bool
-	{
-		if (this.k == '')
-			if (other.kind != '' && other.kind != 'normal')
-				return false;
+  @:op(A == B)
+  public function op_equals(other:SongNoteData):Bool
+  {
+    if (this.k == '')
+      if (other.kind != '' && other.kind != 'normal')
+        return false;
 
-		return this.t == other.time && this.d == other.data && this.l == other.length;
-	}
+    return this.t == other.time && this.d == other.data && this.l == other.length;
+  }
 
-	@:op(A != B)
-	public function op_notEquals(other:SongNoteData):Bool
-	{
-		return this.t != other.time || this.d != other.data || this.l != other.length || this.k != other.kind;
-	}
+  @:op(A != B)
+  public function op_notEquals(other:SongNoteData):Bool
+  {
+    return this.t != other.time || this.d != other.data || this.l != other.length || this.k != other.kind;
+  }
 
-	@:op(A > B)
-	public function op_greaterThan(other:SongNoteData):Bool
-	{
-		return this.t > other.time;
-	}
+  @:op(A > B)
+  public function op_greaterThan(other:SongNoteData):Bool
+  {
+    return this.t > other.time;
+  }
 
-	@:op(A < B)
-	public function op_lessThan(other:SongNoteData):Bool
-	{
-		return this.t < other.time;
-	}
+  @:op(A < B)
+  public function op_lessThan(other:SongNoteData):Bool
+  {
+    return this.t < other.time;
+  }
 
-	@:op(A >= B)
-	public function op_greaterThanOrEquals(other:SongNoteData):Bool
-	{
-		return this.t >= other.time;
-	}
+  @:op(A >= B)
+  public function op_greaterThanOrEquals(other:SongNoteData):Bool
+  {
+    return this.t >= other.time;
+  }
 
-	@:op(A <= B)
-	public function op_lessThanOrEquals(other:SongNoteData):Bool
-	{
-		return this.t <= other.time;
-	}
+  @:op(A <= B)
+  public function op_lessThanOrEquals(other:SongNoteData):Bool
+  {
+    return this.t <= other.time;
+  }
 }
 
 typedef RawSongEventData =
 {
-	/**
-	 * The timestamp of the event. The timestamp is in the format of the song's time format.
-	 */
-	var t:Float;
+  /**
+   * The timestamp of the event. The timestamp is in the format of the song's time format.
+   */
+  var t:Float;
 
-	/**
-	 * The kind of the event.
-	 * Examples include "FocusCamera" and "PlayAnimation"
-	 * Custom events can be added by scripts with the `ScriptedSongEvent` class.
-	 */
-	var e:String;
+  /**
+   * The kind of the event.
+   * Examples include "FocusCamera" and "PlayAnimation"
+   * Custom events can be added by scripts with the `ScriptedSongEvent` class.
+   */
+  var e:String;
 
-	/**
-	 * The data for the event.
-	 * This can allow the event to include information used for custom behavior.
-	 * Data type depends on the event kind. It can be anything that's JSON serializable.
-	 */
-	var v:Dynamic;
+  /**
+   * The data for the event.
+   * This can allow the event to include information used for custom behavior.
+   * Data type depends on the event kind. It can be anything that's JSON serializable.
+   */
+  var v:DynamicAccess<Dynamic>;
+
+  /**
+   * Whether this event has been activated.
+   * This is only used internally by the game. It should not be serialized.
+   */
+  @:optional var a:Bool;
 }
 
 abstract SongEventData(RawSongEventData)
 {
-	public function new(time:Float, event:String, value:Dynamic = null)
-	{
-		this = {
-			t: time,
-			e: event,
-			v: value
-		};
-	}
+  public function new(time:Float, event:String, value:Dynamic = null)
+  {
+    this = {
+      t: time,
+      e: event,
+      v: value,
+      a: false
+    };
+  }
 
-	public var time(get, set):Float;
+  public var time(get, set):Float;
 
-	public function get_time():Float
-	{
-		return this.t;
-	}
+  public function get_time():Float
+  {
+    return this.t;
+  }
 
-	public function set_time(value:Float):Float
-	{
-		return this.t = value;
-	}
+  public function set_time(value:Float):Float
+  {
+    return this.t = value;
+  }
 
-	public var event(get, set):String;
+  public var stepTime(get, never):Float;
 
-	public function get_event():String
-	{
-		return this.e;
-	}
+  public function get_stepTime():Float
+  {
+    // TODO: Account for changes in BPM.
+    return this.t / Conductor.stepCrochet;
+  }
 
-	public function set_event(value:String):String
-	{
-		return this.e = value;
-	}
+  public var event(get, set):String;
 
-	public var value(get, set):Dynamic;
+  public function get_event():String
+  {
+    return this.e;
+  }
 
-	public function get_value():Dynamic
-	{
-		return this.v;
-	}
+  public function set_event(value:String):String
+  {
+    return this.e = value;
+  }
 
-	public function set_value(value:Dynamic):Dynamic
-	{
-		return this.v = value;
-	}
+  public var value(get, set):Dynamic;
 
-	public inline function getBool():Bool
-	{
-		return cast this.v;
-	}
+  public function get_value():Dynamic
+  {
+    return this.v;
+  }
 
-	public inline function getInt():Int
-	{
-		return cast this.v;
-	}
+  public function set_value(value:Dynamic):Dynamic
+  {
+    return this.v = value;
+  }
 
-	public inline function getFloat():Float
-	{
-		return cast this.v;
-	}
+  public var activated(get, set):Bool;
 
-	public inline function getString():String
-	{
-		return cast this.v;
-	}
+  public function get_activated():Bool
+  {
+    return this.a;
+  }
 
-	public inline function getArray():Array<Dynamic>
-	{
-		return cast this.v;
-	}
+  public function set_activated(value:Bool):Bool
+  {
+    return this.a = value;
+  }
 
-	public inline function getBoolArray():Array<Bool>
-	{
-		return cast this.v;
-	}
+  public inline function getDynamic(key:String):Null<Dynamic>
+  {
+    return this.v.get(key);
+  }
 
-	@:op(A == B)
-	public function op_equals(other:SongEventData):Bool
-	{
-		return this.t == other.time && this.e == other.event && this.v == other.value;
-	}
+  public inline function getBool(key:String):Null<Bool>
+  {
+    return cast this.v.get(key);
+  }
 
-	@:op(A != B)
-	public function op_notEquals(other:SongEventData):Bool
-	{
-		return this.t != other.time || this.e != other.event || this.v != other.value;
-	}
+  public inline function getInt(key:String):Null<Int>
+  {
+    return cast this.v.get(key);
+  }
 
-	@:op(A > B)
-	public function op_greaterThan(other:SongEventData):Bool
-	{
-		return this.t > other.time;
-	}
+  public inline function getFloat(key:String):Null<Float>
+  {
+    return cast this.v.get(key);
+  }
 
-	@:op(A < B)
-	public function op_lessThan(other:SongEventData):Bool
-	{
-		return this.t < other.time;
-	}
+  public inline function getString(key:String):String
+  {
+    return cast this.v.get(key);
+  }
 
-	@:op(A >= B)
-	public function op_greaterThanOrEquals(other:SongEventData):Bool
-	{
-		return this.t >= other.time;
-	}
+  public inline function getArray(key:String):Array<Dynamic>
+  {
+    return cast this.v.get(key);
+  }
 
-	@:op(A <= B)
-	public function op_lessThanOrEquals(other:SongEventData):Bool
-	{
-		return this.t <= other.time;
-	}
+  public inline function getBoolArray(key:String):Array<Bool>
+  {
+    return cast this.v.get(key);
+  }
+
+  @:op(A == B)
+  public function op_equals(other:SongEventData):Bool
+  {
+    return this.t == other.time && this.e == other.event && this.v == other.value;
+  }
+
+  @:op(A != B)
+  public function op_notEquals(other:SongEventData):Bool
+  {
+    return this.t != other.time || this.e != other.event || this.v != other.value;
+  }
+
+  @:op(A > B)
+  public function op_greaterThan(other:SongEventData):Bool
+  {
+    return this.t > other.time;
+  }
+
+  @:op(A < B)
+  public function op_lessThan(other:SongEventData):Bool
+  {
+    return this.t < other.time;
+  }
+
+  @:op(A >= B)
+  public function op_greaterThanOrEquals(other:SongEventData):Bool
+  {
+    return this.t >= other.time;
+  }
+
+  @:op(A <= B)
+  public function op_lessThanOrEquals(other:SongEventData):Bool
+  {
+    return this.t <= other.time;
+  }
 }
 
 abstract SongPlayableChar(RawSongPlayableChar)
 {
-	public function new(girlfriend:String, opponent:String, inst:String = "")
-	{
-		this = {
-			g: girlfriend,
-			o: opponent,
-			i: inst
-		};
-	}
+  public function new(girlfriend:String, opponent:String, inst:String = "")
+  {
+    this = {
+      g: girlfriend,
+      o: opponent,
+      i: inst
+    };
+  }
 
-	public var girlfriend(get, set):String;
+  public var girlfriend(get, set):String;
 
-	public function get_girlfriend():String
-	{
-		return this.g;
-	}
+  public function get_girlfriend():String
+  {
+    return this.g;
+  }
 
-	public function set_girlfriend(value:String):String
-	{
-		return this.g = value;
-	}
+  public function set_girlfriend(value:String):String
+  {
+    return this.g = value;
+  }
 
-	public var opponent(get, set):String;
+  public var opponent(get, set):String;
 
-	public function get_opponent():String
-	{
-		return this.o;
-	}
+  public function get_opponent():String
+  {
+    return this.o;
+  }
 
-	public function set_opponent(value:String):String
-	{
-		return this.o = value;
-	}
+  public function set_opponent(value:String):String
+  {
+    return this.o = value;
+  }
 
-	public var inst(get, set):String;
+  public var inst(get, set):String;
 
-	public function get_inst():String
-	{
-		return this.i;
-	}
+  public function get_inst():String
+  {
+    return this.i;
+  }
 
-	public function set_inst(value:String):String
-	{
-		return this.i = value;
-	}
+  public function set_inst(value:String):String
+  {
+    return this.i = value;
+  }
 }
 
 typedef RawSongChartData =
 {
-	var version:Version;
+  var version:Version;
 
-	var scrollSpeed:DynamicAccess<Float>;
-	var events:Array<SongEventData>;
-	var notes:DynamicAccess<Array<SongNoteData>>;
-	var generatedBy:String;
+  var scrollSpeed:DynamicAccess<Float>;
+  var events:Array<SongEventData>;
+  var notes:DynamicAccess<Array<SongNoteData>>;
+  var generatedBy:String;
 };
 
 @:forward
 abstract SongChartData(RawSongChartData)
 {
-	public function new(scrollSpeed:Float, events:Array<SongEventData>, notes:Array<SongNoteData>)
-	{
-		this = {
-			version: SongMigrator.CHART_VERSION,
+  public function new(scrollSpeed:Float, events:Array<SongEventData>, notes:Array<SongNoteData>)
+  {
+    this = {
+      version: SongMigrator.CHART_VERSION,
 
-			events: events,
-			notes: {
-				normal: notes
-			},
-			scrollSpeed: {
-				normal: scrollSpeed
-			},
-			generatedBy: SongValidator.DEFAULT_GENERATEDBY
-		}
-	}
+      events: events,
+      notes: {
+        normal: notes
+      },
+      scrollSpeed: {
+        normal: scrollSpeed
+      },
+      generatedBy: SongValidator.DEFAULT_GENERATEDBY
+    }
+  }
 
-	public function getScrollSpeed(diff:String = 'default'):Float
-	{
-		var result:Float = this.scrollSpeed.get(diff);
+  public function getScrollSpeed(diff:String = 'default'):Float
+  {
+    var result:Float = this.scrollSpeed.get(diff);
 
-		if (result == 0.0 && diff != 'default')
-			return getScrollSpeed('default');
+    if (result == 0.0 && diff != 'default')
+      return getScrollSpeed('default');
 
-		return (result == 0.0) ? 1.0 : result;
-	}
+    return (result == 0.0) ? 1.0 : result;
+  }
 }
 
 typedef RawSongTimeChange =
 {
-	/**
-	 * Timestamp in specified `timeFormat`.
-	 */
-	var t:Float;
+  /**
+   * Timestamp in specified `timeFormat`.
+   */
+  var t:Float;
 
-	/**
-	 * Time in beats (int). The game will calculate further beat values based on this one,
-	 * so it can do it in a simple linear fashion.
-	 */
-	var b:Int;
+  /**
+   * Time in beats (int). The game will calculate further beat values based on this one,
+   * so it can do it in a simple linear fashion.
+   */
+  var b:Int;
 
-	/**
-	 * Quarter notes per minute (float). Cannot be empty in the first element of the list,
-	 * but otherwise it's optional, and defaults to the value of the previous element.
-	 */
-	var bpm:Float;
+  /**
+   * Quarter notes per minute (float). Cannot be empty in the first element of the list,
+   * but otherwise it's optional, and defaults to the value of the previous element.
+   */
+  var bpm:Float;
 
-	/**
-	 * Time signature numerator (int). Optional, defaults to 4.
-	 */
-	var n:Int;
+  /**
+   * Time signature numerator (int). Optional, defaults to 4.
+   */
+  var n:Int;
 
-	/**
-	 * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two.
-	 */
-	var d:Int;
+  /**
+   * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two.
+   */
+  var d:Int;
 
-	/**
-	 * Beat tuplets (Array<int> or int). This defines how many steps each beat is divided into.
-	 * It can either be an array of length `n` (see above) or a single integer number.
-	 * Optional, defaults to `[4]`.
-	 */
-	var bt:OneOfTwo<Int, Array<Int>>;
+  /**
+   * Beat tuplets (Array<int> or int). This defines how many steps each beat is divided into.
+   * It can either be an array of length `n` (see above) or a single integer number.
+   * Optional, defaults to `[4]`.
+   */
+  var bt:OneOfTwo<Int, Array<Int>>;
 }
 
 /**
@@ -758,101 +786,101 @@ typedef RawSongTimeChange =
  */
 abstract SongTimeChange(RawSongTimeChange)
 {
-	public function new(timeStamp:Float, beatTime:Int, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array<Int>)
-	{
-		this = {
-			t: timeStamp,
-			b: beatTime,
-			bpm: bpm,
-			n: timeSignatureNum,
-			d: timeSignatureDen,
-			bt: beatTuplets,
-		}
-	}
+  public function new(timeStamp:Float, beatTime:Int, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array<Int>)
+  {
+    this = {
+      t: timeStamp,
+      b: beatTime,
+      bpm: bpm,
+      n: timeSignatureNum,
+      d: timeSignatureDen,
+      bt: beatTuplets,
+    }
+  }
 
-	public var timeStamp(get, set):Float;
+  public var timeStamp(get, set):Float;
 
-	public function get_timeStamp():Float
-	{
-		return this.t;
-	}
+  public function get_timeStamp():Float
+  {
+    return this.t;
+  }
 
-	public function set_timeStamp(value:Float):Float
-	{
-		return this.t = value;
-	}
+  public function set_timeStamp(value:Float):Float
+  {
+    return this.t = value;
+  }
 
-	public var beatTime(get, set):Int;
+  public var beatTime(get, set):Int;
 
-	public function get_beatTime():Int
-	{
-		return this.b;
-	}
+  public function get_beatTime():Int
+  {
+    return this.b;
+  }
 
-	public function set_beatTime(value:Int):Int
-	{
-		return this.b = value;
-	}
+  public function set_beatTime(value:Int):Int
+  {
+    return this.b = value;
+  }
 
-	public var bpm(get, set):Float;
+  public var bpm(get, set):Float;
 
-	public function get_bpm():Float
-	{
-		return this.bpm;
-	}
+  public function get_bpm():Float
+  {
+    return this.bpm;
+  }
 
-	public function set_bpm(value:Float):Float
-	{
-		return this.bpm = value;
-	}
+  public function set_bpm(value:Float):Float
+  {
+    return this.bpm = value;
+  }
 
-	public var timeSignatureNum(get, set):Int;
+  public var timeSignatureNum(get, set):Int;
 
-	public function get_timeSignatureNum():Int
-	{
-		return this.n;
-	}
+  public function get_timeSignatureNum():Int
+  {
+    return this.n;
+  }
 
-	public function set_timeSignatureNum(value:Int):Int
-	{
-		return this.n = value;
-	}
+  public function set_timeSignatureNum(value:Int):Int
+  {
+    return this.n = value;
+  }
 
-	public var timeSignatureDen(get, set):Int;
+  public var timeSignatureDen(get, set):Int;
 
-	public function get_timeSignatureDen():Int
-	{
-		return this.d;
-	}
+  public function get_timeSignatureDen():Int
+  {
+    return this.d;
+  }
 
-	public function set_timeSignatureDen(value:Int):Int
-	{
-		return this.d = value;
-	}
+  public function set_timeSignatureDen(value:Int):Int
+  {
+    return this.d = value;
+  }
 
-	public var beatTuplets(get, set):Array<Int>;
+  public var beatTuplets(get, set):Array<Int>;
 
-	public function get_beatTuplets():Array<Int>
-	{
-		if (Std.isOfType(this.bt, Int))
-		{
-			return [this.bt];
-		}
-		else
-		{
-			return this.bt;
-		}
-	}
+  public function get_beatTuplets():Array<Int>
+  {
+    if (Std.isOfType(this.bt, Int))
+    {
+      return [this.bt];
+    }
+    else
+    {
+      return this.bt;
+    }
+  }
 
-	public function set_beatTuplets(value:Array<Int>):Array<Int>
-	{
-		return this.bt = value;
-	}
+  public function set_beatTuplets(value:Array<Int>):Array<Int>
+  {
+    return this.bt = value;
+  }
 }
 
 enum abstract SongTimeFormat(String) from String to String
 {
-	var TICKS = "ticks";
-	var FLOAT = "float";
-	var MILLISECONDS = "ms";
+  var TICKS = "ticks";
+  var FLOAT = "float";
+  var MILLISECONDS = "ms";
 }
diff --git a/source/funkin/play/song/SongDataUtils.hx b/source/funkin/play/song/SongDataUtils.hx
index 0c03c1e36..834aaa64f 100644
--- a/source/funkin/play/song/SongDataUtils.hx
+++ b/source/funkin/play/song/SongDataUtils.hx
@@ -26,6 +26,22 @@ class SongDataUtils
 		});
 	}
 
+	/**
+	 * Given an array of SongEventData objects, return a new array of SongEventData objects
+	 * whose timestamps are shifted by the given amount.
+	 * Does not mutate the original array.
+	 * 
+	 * @param events The events to modify.
+	 * @param offset The time difference to apply in milliseconds.
+	 */
+	public static function offsetSongEventData(events:Array<SongEventData>, offset:Int):Array<SongEventData>
+	{
+		return events.map(function(event:SongEventData):SongEventData
+		{
+			return new SongEventData(event.time + offset, event.event, event.value);
+		});
+	}
+
 	/**
 	 * Return a new array without a certain subset of notes from an array of SongNoteData objects.
 	 * Does not mutate the original array.
@@ -94,11 +110,21 @@ class SongDataUtils
 	 * 
 	 * Offset the provided array of notes such that the first note is at 0 milliseconds.
 	 */
-	public static function buildClipboard(notes:Array<SongNoteData>):Array<SongNoteData>
+	public static function buildNoteClipboard(notes:Array<SongNoteData>):Array<SongNoteData>
 	{
 		return offsetSongNoteData(sortNotes(notes), -Std.int(notes[0].time));
 	}
 
+	/**
+	 * Prepare an array of events to be used as the clipboard data.
+	 * 
+	 * Offset the provided array of events such that the first event is at 0 milliseconds.
+	 */
+	public static function buildEventClipboard(events:Array<SongEventData>):Array<SongEventData>
+	{
+		return offsetSongEventData(sortEvents(events), -Std.int(events[0].time));
+	}
+
 	/**
 	 * Sort an array of notes by strum time.
 	 */
@@ -113,39 +139,55 @@ class SongDataUtils
 	}
 
 	/**
-	 * Serialize an array of note data and write it to the clipboard.
+	 * Sort an array of events by strum time.
 	 */
-	public static function writeNotesToClipboard(notes:Array<SongNoteData>):Void
+	public static function sortEvents(events:Array<SongEventData>, ?desc:Bool = false):Array<SongEventData>
 	{
-		var notesString = SerializerUtil.toJSON(notes);
+		// TODO: Modifies the array in place. Is this okay?
+		events.sort(function(a:SongEventData, b:SongEventData):Int
+		{
+			return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.time, b.time);
+		});
+		return events;
+	}
 
-		ClipboardUtil.setClipboard(notesString);
+	/**
+	 * Serialize note and event data and write it to the clipboard.
+	 */
+	public static function writeItemsToClipboard(data:SongClipboardItems):Void
+	{
+		var dataString = SerializerUtil.toJSON(data);
 
-		trace('Wrote ' + notes.length + ' notes to clipboard.');
+		ClipboardUtil.setClipboard(dataString);
 
-		trace(notesString);
+		trace('Wrote ' + data.notes.length + ' notes and ' + data.events.length + ' events to clipboard.');
+
+		trace(dataString);
 	}
 
 	/**
 	 * Read an array of note data from the clipboard and deserialize it.
 	 */
-	public static function readNotesFromClipboard():Array<SongNoteData>
+	public static function readItemsFromClipboard():SongClipboardItems
 	{
 		var notesString = ClipboardUtil.getClipboard();
 
-		trace('Read ' + notesString.length + ' characters from clipboard.');
+		trace('Read ${notesString.length} characters from clipboard.');
 
-		var notes:Array<SongNoteData> = notesString.parseJSON();
+		var data:SongClipboardItems = notesString.parseJSON();
 
-		if (notes == null)
+		if (data == null)
 		{
 			trace('Failed to parse notes from clipboard.');
-			return [];
+			return {
+				notes: [],
+				events: []
+			};
 		}
 		else
 		{
-			trace('Parsed ' + notes.length + ' notes from clipboard.');
-			return notes;
+			trace('Parsed ' + data.notes.length + ' notes and ' + data.events.length + ' from clipboard.');
+			return data;
 		}
 	}
 
@@ -160,6 +202,17 @@ class SongDataUtils
 		});
 	}
 
+	/**
+	 * Filter a list of events to only include events that are within the given time range.
+	 */
+	public static function getEventsInTimeRange(events:Array<SongEventData>, start:Float, end:Float):Array<SongEventData>
+	{
+		return events.filter(function(event:SongEventData):Bool
+		{
+			return event.time >= start && event.time <= end;
+		});
+	}
+
 	/**
 	 * Filter a list of notes to only include notes whose data is within the given range.
 	 */
@@ -182,3 +235,9 @@ class SongDataUtils
 		});
 	}
 }
+
+typedef SongClipboardItems =
+{
+	notes:Array<SongNoteData>,
+	events:Array<SongEventData>
+}
diff --git a/source/funkin/play/song/SongMigrator.hx b/source/funkin/play/song/SongMigrator.hx
index 9199d7d14..1872585d0 100644
--- a/source/funkin/play/song/SongMigrator.hx
+++ b/source/funkin/play/song/SongMigrator.hx
@@ -6,74 +6,74 @@ import funkin.util.VersionUtil;
 
 class SongMigrator
 {
-	/**
-	 * The current latest version string for the song data format.
-	 * Handle breaking changes by incrementing this value
-	 * and adding migration to the SongMigrator class.
-	 */
-	public static final CHART_VERSION:String = "2.0.0";
+  /**
+   * The current latest version string for the song data format.
+   * Handle breaking changes by incrementing this value
+   * and adding migration to the SongMigrator class.
+   */
+  public static final CHART_VERSION:String = "2.0.0";
 
-	public static final CHART_VERSION_RULE:String = "2.0.x";
+  public static final CHART_VERSION_RULE:String = "2.0.x";
 
-	public static function migrateSongMetadata(jsonData:Dynamic, songId:String):SongMetadata
-	{
-		if (jsonData.version)
-		{
-			if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE))
-			{
-				trace('[SONGDATA] Song (${songId}) metadata version (${jsonData.version}) is valid and up-to-date.');
+  public static function migrateSongMetadata(jsonData:Dynamic, songId:String):SongMetadata
+  {
+    if (jsonData.version)
+    {
+      if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE))
+      {
+        trace('Song (${songId}) metadata version (${jsonData.version}) is valid and up-to-date.');
 
-				var songMetadata:SongMetadata = cast jsonData;
+        var songMetadata:SongMetadata = cast jsonData;
 
-				return songMetadata;
-			}
-			else
-			{
-				trace('[SONGDATA] Song (${songId}) metadata version (${jsonData.version}) is outdated.');
-				switch (jsonData.version)
-				{
-					// TODO: Add migration functions as cases here.
-					default:
-						// Unknown version.
-						trace('[SONGDATA] Song (${songId}) unknown metadata version: ${jsonData.version}');
-				}
-			}
-		}
-		else
-		{
-			trace('[SONGDATA] Song metadata version is missing.');
-		}
-		return null;
-	}
+        return songMetadata;
+      }
+      else
+      {
+        trace('Song (${songId}) metadata version (${jsonData.version}) is outdated.');
+        switch (jsonData.version)
+        {
+          // TODO: Add migration functions as cases here.
+          default:
+            // Unknown version.
+            trace('Song (${songId}) unknown metadata version: ${jsonData.version}');
+        }
+      }
+    }
+    else
+    {
+      trace('Song metadata version is missing.');
+    }
+    return null;
+  }
 
-	public static function migrateSongChartData(jsonData:Dynamic, songId:String):SongChartData
-	{
-		if (jsonData.version)
-		{
-			if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE))
-			{
-				trace('[SONGDATA] Song (${songId}) chart version (${jsonData.version}) is valid and up-to-date.');
+  public static function migrateSongChartData(jsonData:Dynamic, songId:String):SongChartData
+  {
+    if (jsonData.version)
+    {
+      if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE))
+      {
+        trace('Song (${songId}) chart version (${jsonData.version}) is valid and up-to-date.');
 
-				var songChartData:SongChartData = cast jsonData;
+        var songChartData:SongChartData = cast jsonData;
 
-				return songChartData;
-			}
-			else
-			{
-				trace('[SONGDATA] Song (${songId}) chart version (${jsonData.version}) is outdated.');
-				switch (jsonData.version)
-				{
-					// TODO: Add migration functions as cases here.
-					default:
-						// Unknown version.
-						trace('[SONGDATA] Song (${songId}) unknown chart version: ${jsonData.version}');
-				}
-			}
-		}
-		else
-		{
-			trace('[SONGDATA] Song chart version is missing.');
-		}
-		return null;
-	}
+        return songChartData;
+      }
+      else
+      {
+        trace('Song (${songId}) chart version (${jsonData.version}) is outdated.');
+        switch (jsonData.version)
+        {
+          // TODO: Add migration functions as cases here.
+          default:
+            // Unknown version.
+            trace('Song (${songId}) unknown chart version: ${jsonData.version}');
+        }
+      }
+    }
+    else
+    {
+      trace('Song chart version is missing.');
+    }
+    return null;
+  }
 }
diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index 39e46bccf..298b38d43 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -15,393 +15,358 @@ typedef AnimationFinishedCallback = String->Void;
  */
 class Bopper extends FlxSprite implements IPlayStateScriptedClass
 {
-	/**
-	 * The bopper plays the dance animation once every `danceEvery` beats.
-	 * Set to 0 to disable idle animation.
-	 */
-	public var danceEvery:Int = 1;
-
-	/**
-	 * Whether the bopper should dance left and right.
-	 * - If true, alternate playing `danceLeft` and `danceRight`.
-	 * - If false, play `idle` every time.
-	 * 
-	 * You can manually set this value, or you can leave it as `null` to determine it automatically.
-	 */
-	public var shouldAlternate:Null<Bool> = null;
-
-	/**
-	 * Offset the character's sprite by this much when playing each animation.
-	 */
-	public var animationOffsets:Map<String, Array<Float>> = new Map<String, Array<Float>>();
-
-	/**
-	 * Add a suffix to the `idle` animation (or `danceLeft` and `danceRight` animations)
-	 * that this bopper will play.
-	 */
-	public var idleSuffix(default, set):String = "";
-
-	/**
-	 * Whether this bopper should bop every beat. By default it's true, but when used
-	 * for characters/players, it should be false so it doesn't cut off their animations!!!!!
-	 */
-	public var shouldBop:Bool = true;
-
-	function set_idleSuffix(value:String):String
-	{
-		this.idleSuffix = value;
-		this.dance();
-		return value;
-	}
-
-	/**
-	 * The offset of the character relative to the position specified by the stage.
-	 */
-	public var globalOffsets(default, set):Array<Float> = [0, 0];
-
-	function set_globalOffsets(value:Array<Float>)
-	{
-		if (globalOffsets == null)
-			globalOffsets = [0, 0];
-		if (globalOffsets == value)
-			return value;
-
-		var xDiff = globalOffsets[0] - value[0];
-		var yDiff = globalOffsets[1] - value[1];
-
-		this.x += xDiff;
-		this.y += yDiff;
-		return animOffsets = value;
-
-	}
-
-	private var animOffsets(default, set):Array<Float> = [0, 0];
-
-	public var originalPosition:FlxPoint = new FlxPoint(0, 0);
-
-	function set_animOffsets(value:Array<Float>)
-	{
-		if (animOffsets == null)
-			animOffsets = [0, 0];
-		if (animOffsets == value)
-			return value;
-
-		var xDiff = animOffsets[0] - value[0];
-		var yDiff = animOffsets[1] - value[1];
-
-		this.x += xDiff;
-		this.y += yDiff;
-
-		return animOffsets = value;
-	}
-
-	/**
-	 * Whether to play `danceRight` next iteration.
-	 * Only used when `shouldAlternate` is true.
-	 */
-	var hasDanced:Bool = false;
-
-	public function new(danceEvery:Int = 1)
-	{
-		super();
-		this.danceEvery = danceEvery;
-
-		this.animation.callback = this.onAnimationFrame;
-		this.animation.finishCallback = this.onAnimationFinished;
-	}
-
-	/**
-	 * Called when an animation finishes.
-	 * @param name The name of the animation that just finished.
-	 */
-	function onAnimationFinished(name:String)
-	{
-		// TODO: Can we make a system of like, animation priority or something?
-		if (!canPlayOtherAnims)
-		{
-			canPlayOtherAnims = true;
-		}
-	}
-
-	/**
-	 * Called when the current animation's frame changes.
-	 * @param name The name of the current animation.
-	 * @param frameNumber The number of the current frame.
-	 * @param frameIndex The index of the current frame.
-	 * 
-	 * For example, if an animation was defined as having the indexes [3, 0, 1, 2],
-	 * then the first callback would have frameNumber = 0 and frameIndex = 3.
-	 */
-	function onAnimationFrame(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1)
-	{
-		// Do nothing by default.
-		// This can be overridden by, for example, scripted characters,
-		// or by calling `animationFrame.add()`.
-		
-		// Try not to do anything expensive here, it runs many times a second.
-	}
-
-	/**
-	 * If this Bopper was defined by the stage, return the prop to its original position.
-	 */
-	public function resetPosition()
-	{
-		this.x = originalPosition.x + animOffsets[0];
-		this.y = originalPosition.y + animOffsets[1];
-	}
-
-	function update_shouldAlternate():Void
-	{
-		if (hasAnimation('danceLeft'))
-		{
-			this.shouldAlternate = true;
-		}
-	}
-
-	/**
-	 * Called once every beat of the song.
-	 */
-	public function onBeatHit(event:SongTimeScriptEvent):Void
-	{
-		if (danceEvery > 0 && event.beat % danceEvery == 0)
-		{
-			dance(shouldBop);
-		}
-	}
-
-	/**
-	 * Called every `danceEvery` beats of the song.
-	 */
-	public function dance(forceRestart:Bool = false):Void
-	{
-		if (this.animation == null)
-		{
-			return;
-		}
-
-		if (shouldAlternate == null)
-		{
-			update_shouldAlternate();
-		}
-
-		if (shouldAlternate)
-		{
-			if (hasDanced)
-			{
-				playAnimation('danceRight$idleSuffix', forceRestart);
-			}
-			else
-			{
-				playAnimation('danceLeft$idleSuffix', forceRestart);
-			}
-			hasDanced = !hasDanced;
-		}
-		else
-		{
-			playAnimation('idle$idleSuffix', forceRestart);
-		}
-	}
-
-	public function hasAnimation(id:String):Bool
-	{
-		if (this.animation == null)
-			return false;
-
-		return this.animation.getByName(id) != null;
-	}
-
-	/**
-	 * Ensure that a given animation exists before playing it.
-	 * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play.
-	 * @param name 
-	 */
-	function correctAnimationName(name:String)
-	{
-		// If the animation exists, we're good.
-		if (hasAnimation(name))
-			return name;
-
-		trace('[BOPPER] Animation "$name" does not exist!');
-
-		// Attempt to strip a `-alt` suffix, if it exists.
-		if (name.lastIndexOf('-') != -1)
-		{
-			var correctName = name.substring(0, name.lastIndexOf('-'));
-			trace('[BOPPER] Attempting to fallback to "$correctName"');
-			return correctAnimationName(correctName);
-		}
-		else
-		{
-			if (name != 'idle')
-			{
-				trace('[BOPPER] Attempting to fallback to "idle"');
-				return correctAnimationName('idle');
-			}
-			else
-			{
-				trace('[BOPPER] Failing animation playback.');
-				return null;
-			}
-		}
-	}
-
-	public var canPlayOtherAnims:Bool = true;
-
-	/**
-	 * @param name The name of the animation to play.
-	 * @param restart Whether to restart the animation if it is already playing.
-	 * @param ignoreOther Whether to ignore all other animation inputs, until this one is done playing
-	 */
-	public function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void
-	{
-		if (!canPlayOtherAnims && !ignoreOther)
-			return;
-
-		var correctName = correctAnimationName(name);
-		if (correctName == null)
-			return;
-
-		this.animation.play(correctName, restart, false, 0);
-
-		if (ignoreOther)
-		{
-			canPlayOtherAnims = false;
-		}
-
-		applyAnimationOffsets(correctName);
-	}
-
-	var forceAnimationTimer:FlxTimer = new FlxTimer();
-
-	/**
-	 * @param name The animation to play.
-	 * @param duration The duration in which other (non-forced) animations will be skipped, in seconds (NOT MILLISECONDS).
-	 */
-	public function forceAnimationForDuration(name:String, duration:Float):Void
-	{
-		// TODO: Might be nice to rework this function, maybe have a numbered priority system?
-
-		if (this.animation == null)
-			return;
-
-		var correctName = correctAnimationName(name);
-		if (correctName == null)
-			return;
-
-		this.animation.play(correctName, false, false);
-		applyAnimationOffsets(correctName);
-
-		canPlayOtherAnims = false;
-		forceAnimationTimer.start(duration, (timer) ->
-		{
-			canPlayOtherAnims = true;
-		}, 1);
-	}
-
-	function applyAnimationOffsets(name:String)
-	{
-		var offsets = animationOffsets.get(name);
-		if (offsets != null)
-		{
-			this.animOffsets = [offsets[0] + globalOffsets[0], offsets[1] + globalOffsets[1]];
-		}
-		else
-		{
-			this.animOffsets = globalOffsets;
-		}
-	}
-
-	public function isAnimationFinished():Bool
-	{
-		return this.animation.finished;
-	}
-
-	public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void
-	{
-		animationOffsets.set(name, [xOffset, yOffset]);
-	}
-
-	/**
-	 * Returns the name of the animation that is currently playing.
-	 * If no animation is playing (usually this means the character is BROKEN!),
-	 *   returns an empty string to prevent NPEs.
-	 */
-	public function getCurrentAnimation():String
-	{
-		if (this.animation == null || this.animation.curAnim == null)
-			return "";
-		return this.animation.curAnim.name;
-	}
-
-	public function onScriptEvent(event:ScriptEvent)
-	{
-	}
-
-	public function onCreate(event:ScriptEvent)
-	{
-	}
-
-	public function onDestroy(event:ScriptEvent)
-	{
-	}
-
-	public function onUpdate(event:UpdateScriptEvent)
-	{
-	}
-
-	public function onPause(event:PauseScriptEvent)
-	{
-	}
-
-	public function onResume(event:ScriptEvent)
-	{
-	}
-
-	public function onSongStart(event:ScriptEvent)
-	{
-	}
-
-	public function onSongEnd(event:ScriptEvent)
-	{
-	}
-
-	public function onGameOver(event:ScriptEvent)
-	{
-	}
-
-	public function onNoteHit(event:NoteScriptEvent)
-	{
-	}
-
-	public function onNoteMiss(event:NoteScriptEvent)
-	{
-	}
-
-	public function onNoteGhostMiss(event:GhostMissNoteScriptEvent)
-	{
-	}
-
-	public function onStepHit(event:SongTimeScriptEvent)
-	{
-	}
-
-	public function onCountdownStart(event:CountdownScriptEvent)
-	{
-	}
-
-	public function onCountdownStep(event:CountdownScriptEvent)
-	{
-	}
-
-	public function onCountdownEnd(event:CountdownScriptEvent)
-	{
-	}
-
-	public function onSongLoaded(event:SongLoadScriptEvent)
-	{
-	}
-
-	public function onSongRetry(event:ScriptEvent)
-	{
-	}
+  /**
+   * The bopper plays the dance animation once every `danceEvery` beats.
+   * Set to 0 to disable idle animation.
+   */
+  public var danceEvery:Int = 1;
+
+  /**
+   * Whether the bopper should dance left and right.
+   * - If true, alternate playing `danceLeft` and `danceRight`.
+   * - If false, play `idle` every time.
+   * 
+   * You can manually set this value, or you can leave it as `null` to determine it automatically.
+   */
+  public var shouldAlternate:Null<Bool> = null;
+
+  /**
+   * Offset the character's sprite by this much when playing each animation.
+   */
+  public var animationOffsets:Map<String, Array<Float>> = new Map<String, Array<Float>>();
+
+  /**
+   * Add a suffix to the `idle` animation (or `danceLeft` and `danceRight` animations)
+   * that this bopper will play.
+   */
+  public var idleSuffix(default, set):String = "";
+
+  /**
+   * Whether this bopper should bop every beat. By default it's true, but when used
+   * for characters/players, it should be false so it doesn't cut off their animations!!!!!
+   */
+  public var shouldBop:Bool = true;
+
+  function set_idleSuffix(value:String):String
+  {
+    this.idleSuffix = value;
+    this.dance();
+    return value;
+  }
+
+  /**
+   * The offset of the character relative to the position specified by the stage.
+   */
+  public var globalOffsets(default, set):Array<Float> = [0, 0];
+
+  function set_globalOffsets(value:Array<Float>)
+  {
+    if (globalOffsets == null)
+      globalOffsets = [0, 0];
+    if (globalOffsets == value)
+      return value;
+
+    var xDiff = globalOffsets[0] - value[0];
+    var yDiff = globalOffsets[1] - value[1];
+
+    this.x += xDiff;
+    this.y += yDiff;
+    return animOffsets = value;
+  }
+
+  private var animOffsets(default, set):Array<Float> = [0, 0];
+
+  public var originalPosition:FlxPoint = new FlxPoint(0, 0);
+
+  function set_animOffsets(value:Array<Float>)
+  {
+    if (animOffsets == null)
+      animOffsets = [0, 0];
+    if (animOffsets == value)
+      return value;
+
+    var xDiff = animOffsets[0] - value[0];
+    var yDiff = animOffsets[1] - value[1];
+
+    this.x += xDiff;
+    this.y += yDiff;
+
+    return animOffsets = value;
+  }
+
+  /**
+   * Whether to play `danceRight` next iteration.
+   * Only used when `shouldAlternate` is true.
+   */
+  var hasDanced:Bool = false;
+
+  public function new(danceEvery:Int = 1)
+  {
+    super();
+    this.danceEvery = danceEvery;
+
+    this.animation.callback = this.onAnimationFrame;
+    this.animation.finishCallback = this.onAnimationFinished;
+  }
+
+  /**
+   * Called when an animation finishes.
+   * @param name The name of the animation that just finished.
+   */
+  function onAnimationFinished(name:String)
+  {
+    // TODO: Can we make a system of like, animation priority or something?
+    if (!canPlayOtherAnims)
+    {
+      canPlayOtherAnims = true;
+    }
+  }
+
+  /**
+   * Called when the current animation's frame changes.
+   * @param name The name of the current animation.
+   * @param frameNumber The number of the current frame.
+   * @param frameIndex The index of the current frame.
+   * 
+   * For example, if an animation was defined as having the indexes [3, 0, 1, 2],
+   * then the first callback would have frameNumber = 0 and frameIndex = 3.
+   */
+  function onAnimationFrame(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1)
+  {
+    // Do nothing by default.
+    // This can be overridden by, for example, scripted characters,
+    // or by calling `animationFrame.add()`.
+
+    // Try not to do anything expensive here, it runs many times a second.
+  }
+
+  /**
+   * If this Bopper was defined by the stage, return the prop to its original position.
+   */
+  public function resetPosition()
+  {
+    this.x = originalPosition.x + animOffsets[0];
+    this.y = originalPosition.y + animOffsets[1];
+  }
+
+  function update_shouldAlternate():Void
+  {
+    if (hasAnimation('danceLeft'))
+    {
+      this.shouldAlternate = true;
+    }
+  }
+
+  /**
+   * Called once every beat of the song.
+   */
+  public function onBeatHit(event:SongTimeScriptEvent):Void
+  {
+    if (danceEvery > 0 && event.beat % danceEvery == 0)
+    {
+      dance(shouldBop);
+    }
+  }
+
+  /**
+   * Called every `danceEvery` beats of the song.
+   */
+  public function dance(forceRestart:Bool = false):Void
+  {
+    if (this.animation == null)
+    {
+      return;
+    }
+
+    if (shouldAlternate == null)
+    {
+      update_shouldAlternate();
+    }
+
+    if (shouldAlternate)
+    {
+      if (hasDanced)
+      {
+        playAnimation('danceRight$idleSuffix', forceRestart);
+      }
+      else
+      {
+        playAnimation('danceLeft$idleSuffix', forceRestart);
+      }
+      hasDanced = !hasDanced;
+    }
+    else
+    {
+      playAnimation('idle$idleSuffix', forceRestart);
+    }
+  }
+
+  public function hasAnimation(id:String):Bool
+  {
+    if (this.animation == null)
+      return false;
+
+    return this.animation.getByName(id) != null;
+  }
+
+  /**
+   * Ensure that a given animation exists before playing it.
+   * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play.
+   * @param name 
+   */
+  function correctAnimationName(name:String)
+  {
+    // If the animation exists, we're good.
+    if (hasAnimation(name))
+      return name;
+
+    trace('[BOPPER] Animation "$name" does not exist!');
+
+    // Attempt to strip a `-alt` suffix, if it exists.
+    if (name.lastIndexOf('-') != -1)
+    {
+      var correctName = name.substring(0, name.lastIndexOf('-'));
+      trace('[BOPPER] Attempting to fallback to "$correctName"');
+      return correctAnimationName(correctName);
+    }
+    else
+    {
+      if (name != 'idle')
+      {
+        trace('[BOPPER] Attempting to fallback to "idle"');
+        return correctAnimationName('idle');
+      }
+      else
+      {
+        trace('[BOPPER] Failing animation playback.');
+        return null;
+      }
+    }
+  }
+
+  public var canPlayOtherAnims:Bool = true;
+
+  /**
+   * @param name The name of the animation to play.
+   * @param restart Whether to restart the animation if it is already playing.
+   * @param ignoreOther Whether to ignore all other animation inputs, until this one is done playing
+   */
+  public function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void
+  {
+    if (!canPlayOtherAnims && !ignoreOther)
+      return;
+
+    var correctName = correctAnimationName(name);
+    if (correctName == null)
+      return;
+
+    this.animation.play(correctName, restart, false, 0);
+
+    if (ignoreOther)
+    {
+      canPlayOtherAnims = false;
+    }
+
+    applyAnimationOffsets(correctName);
+  }
+
+  var forceAnimationTimer:FlxTimer = new FlxTimer();
+
+  /**
+   * @param name The animation to play.
+   * @param duration The duration in which other (non-forced) animations will be skipped, in seconds (NOT MILLISECONDS).
+   */
+  public function forceAnimationForDuration(name:String, duration:Float):Void
+  {
+    // TODO: Might be nice to rework this function, maybe have a numbered priority system?
+
+    if (this.animation == null)
+      return;
+
+    var correctName = correctAnimationName(name);
+    if (correctName == null)
+      return;
+
+    this.animation.play(correctName, false, false);
+    applyAnimationOffsets(correctName);
+
+    canPlayOtherAnims = false;
+    forceAnimationTimer.start(duration, (timer) ->
+    {
+      canPlayOtherAnims = true;
+    }, 1);
+  }
+
+  function applyAnimationOffsets(name:String)
+  {
+    var offsets = animationOffsets.get(name);
+    if (offsets != null)
+    {
+      this.animOffsets = [offsets[0] + globalOffsets[0], offsets[1] + globalOffsets[1]];
+    }
+    else
+    {
+      this.animOffsets = globalOffsets;
+    }
+  }
+
+  public function isAnimationFinished():Bool
+  {
+    return this.animation.finished;
+  }
+
+  public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void
+  {
+    animationOffsets.set(name, [xOffset, yOffset]);
+  }
+
+  /**
+   * Returns the name of the animation that is currently playing.
+   * If no animation is playing (usually this means the character is BROKEN!),
+   *   returns an empty string to prevent NPEs.
+   */
+  public function getCurrentAnimation():String
+  {
+    if (this.animation == null || this.animation.curAnim == null)
+      return "";
+    return this.animation.curAnim.name;
+  }
+
+  public function onScriptEvent(event:ScriptEvent) {}
+
+  public function onCreate(event:ScriptEvent) {}
+
+  public function onDestroy(event:ScriptEvent) {}
+
+  public function onUpdate(event:UpdateScriptEvent) {}
+
+  public function onPause(event:PauseScriptEvent) {}
+
+  public function onResume(event:ScriptEvent) {}
+
+  public function onSongStart(event:ScriptEvent) {}
+
+  public function onSongEnd(event:ScriptEvent) {}
+
+  public function onGameOver(event:ScriptEvent) {}
+
+  public function onNoteHit(event:NoteScriptEvent) {}
+
+  public function onNoteMiss(event:NoteScriptEvent) {}
+
+  public function onSongEvent(event:SongEventScriptEvent) {}
+
+  public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {}
+
+  public function onStepHit(event:SongTimeScriptEvent) {}
+
+  public function onCountdownStart(event:CountdownScriptEvent) {}
+
+  public function onCountdownStep(event:CountdownScriptEvent) {}
+
+  public function onCountdownEnd(event:CountdownScriptEvent) {}
+
+  public function onSongLoaded(event:SongLoadScriptEvent) {}
+
+  public function onSongRetry(event:ScriptEvent) {}
 }
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index 55c609ac8..83790e49d 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -21,621 +21,592 @@ import funkin.util.assets.FlxAnimationUtil;
  */
 class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
 {
-	public final stageId:String;
-	public final stageName:String;
-
-	final _data:StageData;
-
-	public var camZoom:Float = 1.0;
-
-	var namedProps:Map<String, FlxSprite> = new Map<String, FlxSprite>();
-	var characters:Map<String, BaseCharacter> = new Map<String, BaseCharacter>();
-	var boppers:Array<Bopper> = new Array<Bopper>();
-
-	/**
-	 * The Stage elements get initialized at the beginning of the game.
-	 * They're used to cache the data needed to build the stage,
-	 * then accessed and fleshed out when the stage needs to be built.
-	 * 
-	 * @param stageId 
-	 */
-	public function new(stageId:String)
-	{
-		super();
-
-		this.stageId = stageId;
-		_data = StageDataParser.parseStageData(this.stageId);
-		if (_data == null)
-		{
-			throw 'Could not find stage data for stageId: $stageId';
-		}
-		else
-		{
-			this.stageName = _data.name;
-		}
-	}
-
-	/**
-	 * Called when the player is moving into the PlayState where the song will be played.
-	 */
-	public function onCreate(event:ScriptEvent):Void
-	{
-		buildStage();
-		this.refresh();
-
-		debugIconGroup = new FlxSpriteGroup();
-		debugIconGroup.visible = false;
-		debugIconGroup.zIndex = 1000000;
-		add(debugIconGroup);
-	}
-
-	public function resetStage():Void
-	{
-		// Reset positions of characters.
-		if (getBoyfriend() != null)
-		{
-			getBoyfriend().resetCharacter(false);
-		}
-		else
-		{
-			trace('STAGE RESET: No boyfriend found.');
-		}
-		if (getGirlfriend() != null)
-		{
-			getGirlfriend().resetCharacter(false);
-		}
-		if (getDad() != null)
-		{
-			getDad().resetCharacter(false);
-		}
-
-		// Reset positions of named props.
-		for (dataProp in _data.props)
-		{
-			// Fetch the prop.
-			var prop:FlxSprite = getNamedProp(dataProp.name);
-
-			if (prop != null)
-			{
-				// Reset the position.
-				prop.x = dataProp.position[0];
-				prop.y = dataProp.position[1];
-				prop.zIndex = dataProp.zIndex;
-			}
-		}
-
-		// We can assume unnamed props are not moving.
-	}
-
-	/**
-	 * The default stage construction routine. Called when the stage is going to be played in.
-	 * Instantiates each prop and adds it to the stage, while setting its parameters.
-	 */
-	function buildStage()
-	{
-		trace('Building stage for display: ${this.stageId}');
-
-		this.camZoom = _data.cameraZoom;
-
-		this.debugIconGroup = new FlxSpriteGroup();
-
-		for (dataProp in _data.props)
-		{
-			trace('  Placing prop: ${dataProp.name} (${dataProp.assetPath})');
-
-			var isAnimated = dataProp.animations.length > 0;
-
-			var propSprite:FlxSprite;
-			if (dataProp.danceEvery != 0)
-			{
-				propSprite = new Bopper(dataProp.danceEvery);
-			}
-			else
-			{
-				propSprite = new FlxSprite();
-			}
-
-			if (isAnimated)
-			{
-				// Initalize sprite frames.
-				switch (dataProp.animType)
-				{
-					case "packer":
-						propSprite.frames = Paths.getPackerAtlas(dataProp.assetPath);
-					default: // "sparrow"
-						propSprite.frames = Paths.getSparrowAtlas(dataProp.assetPath);
-				}
-			}
-			else
-			{
-				// Initalize static sprite.
-				propSprite.loadGraphic(Paths.image(dataProp.assetPath));
-
-				// Disables calls to update() for a performance boost.
-				propSprite.active = false;
-			}
-
-			if (propSprite.frames == null || propSprite.frames.numFrames == 0)
-			{
-				trace('    ERROR: Could not build texture for prop.');
-				continue;
-			}
-
-			if (Std.isOfType(dataProp.scale, Array))
-			{
-				propSprite.scale.set(dataProp.scale[0], dataProp.scale[1]);
-			}
-			else
-			{
-				propSprite.scale.set(dataProp.scale);
-			}
-			propSprite.updateHitbox();
-
-			propSprite.x = dataProp.position[0];
-			propSprite.y = dataProp.position[1];
-
-			propSprite.alpha = dataProp.alpha;
-
-			// If pixel, disable antialiasing.
-			propSprite.antialiasing = !dataProp.isPixel;
-
-			propSprite.scrollFactor.x = dataProp.scroll[0];
-			propSprite.scrollFactor.y = dataProp.scroll[1];
-
-			propSprite.zIndex = dataProp.zIndex;
-
-			switch (dataProp.animType)
-			{
-				case "packer":
-					for (propAnim in dataProp.animations)
-					{
-						propSprite.animation.add(propAnim.name, propAnim.frameIndices);
-
-						if (Std.isOfType(propSprite, Bopper))
-						{
-							cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
-						}
-					}
-				default: // "sparrow"
-					FlxAnimationUtil.addAtlasAnimations(propSprite, dataProp.animations);
-					if (Std.isOfType(propSprite, Bopper))
-					{
-						for (propAnim in dataProp.animations)
-						{
-							cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
-						}
-					}
-			}
-
-			if (Std.isOfType(propSprite, Bopper))
-			{
-				for (propAnim in dataProp.animations)
-				{
-					cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
-				}
-				cast(propSprite, Bopper).originalPosition.x = dataProp.position[0];
-				cast(propSprite, Bopper).originalPosition.y = dataProp.position[1];
-			}
-
-			if (dataProp.startingAnimation != null)
-			{
-				propSprite.animation.play(dataProp.startingAnimation);
-			}
-
-			if (Std.isOfType(propSprite, Bopper))
-			{
-				addBopper(cast propSprite, dataProp.name);
-			}
-			else
-			{
-				addProp(propSprite, dataProp.name);
-			}
-			trace('    Prop placed.');
-		}
-	}
-
-	/**
-	 * Add a sprite to the stage.
-	 * @param prop The sprite to add.
-	 * @param name (Optional) A unique name for the sprite.
-	 *   You can call `getNamedProp(name)` to retrieve it later.
-	 */
-	public function addProp(prop:FlxSprite, ?name:String = null)
-	{
-		if (name != null)
-		{
-			namedProps.set(name, prop);
-		}
-		this.add(prop);
-	}
-
-	/**
-	 * Add a sprite to the stage which animates to the beat of the song.
-	 */
-	public function addBopper(bopper:Bopper, ?name:String = null)
-	{
-		boppers.push(bopper);
-		this.addProp(bopper, name);
-	}
-
-	/**
-	 * Refreshes the stage, by redoing the render order of all props.
-	 * It does this based on the `zIndex` of each prop.
-	 */
-	public function refresh()
-	{
-		sort(SortUtil.byZIndex, FlxSort.ASCENDING);
-	}
-
-	public function setShader(shader:FlxShader)
-	{
-		forEachAlive(function(prop:FlxSprite)
-		{
-			prop.shader = shader;
-		});
-	}
-
-	/**
-	 * Adjusts the position and other properties of the soon-to-be child of this sprite group.
-	 * Private helper to avoid duplicate code in `add()` and `insert()`.
-	 *
-	 * @param	Sprite	The sprite or sprite group that is about to be added or inserted into the group.
-	 */
-	override function preAdd(Sprite:FlxSprite):Void
-	{
-		var sprite:FlxSprite = cast Sprite;
-		sprite.x += x;
-		sprite.y += y;
-		sprite.alpha *= alpha;
-		// Don't override scroll factors.
-		// sprite.scrollFactor.copyFrom(scrollFactor);
-		sprite.cameras = _cameras; // _cameras instead of cameras because get_cameras() will not return null
-
-		if (clipRect != null)
-			clipRectTransform(sprite, clipRect);
-	}
-
-	var debugIconGroup:FlxSpriteGroup;
-
-	/**
-	 * Used by the PlayState to add a character to the stage.
-	 */
-	public function addCharacter(character:BaseCharacter, charType:CharacterType)
-	{
-		if (character == null)
-			return;
-
-		#if debug
-		// Temporary marker that shows where the character's location is relative to.
-		// Should display at the stage position of the character (before any offsets).
-		// TODO: Make this a toggle? It's useful to turn on from time to time.
-		var debugIcon:FlxSprite = new FlxSprite(0, 0);
-		var debugIcon2:FlxSprite = new FlxSprite(0, 0);
-		debugIcon.makeGraphic(8, 8, 0xffff00ff);
-		debugIcon2.makeGraphic(8, 8, 0xff00ffff);
-		debugIcon.visible = true;
-		debugIcon2.visible = true;
-		debugIcon.zIndex = 1000000;
-		debugIcon2.zIndex = 1000000;
-		#end
-
-		// Apply position and z-index.
-		var charData:StageDataCharacter = null;
-		switch (charType)
-		{
-			case BF:
-				this.characters.set("bf", character);
-				charData = _data.characters.bf;
-				character.flipX = !character.getDataFlipX();
-				character.initHealthIcon(false);
-			case GF:
-				this.characters.set("gf", character);
-				charData = _data.characters.gf;
-				character.flipX = character.getDataFlipX();
-			case DAD:
-				this.characters.set("dad", character);
-				charData = _data.characters.dad;
-				character.flipX = character.getDataFlipX();
-				character.initHealthIcon(true);
-			default:
-				this.characters.set(character.characterId, character);
-		}
-
-		// Reset the character before adding it to the stage.
-		// This ensures positioning is based on the idle animation.
-		character.resetCharacter(true);
-
-		if (charData != null)
-		{
-			character.zIndex = charData.zIndex;
-
-			// Start with the per-stage character position.
-			// Subtracting the origin ensures characters are positioned relative to their feet.
-			// Subtracting the global offset allows positioning on a per-character basis.
-			character.x = charData.position[0] - character.characterOrigin.x + character.globalOffsets[0];
-			character.y = charData.position[1] - character.characterOrigin.y + character.globalOffsets[1];
-
-			character.originalPosition.x = character.x;
-			character.originalPosition.y = character.y;
-
-			character.cameraFocusPoint.x += charData.cameraOffsets[0];
-			character.cameraFocusPoint.y += charData.cameraOffsets[1];
-
-			#if debug
-			// Draw the debug icon at the character's feet.
-			if (charType == BF || charType == DAD)
-			{
-				debugIcon.x = charData.position[0];
-				debugIcon.y = charData.position[1];
-				debugIcon2.x = character.x;
-				debugIcon2.y = character.y;
-			}
-			#end
-		}
-
-		// Add the character to the scene.
-		this.add(character);
-
-		#if debug
-		debugIconGroup.add(debugIcon);
-		debugIconGroup.add(debugIcon2);
-		#end
-	}
-
-	public inline function getGirlfriendPosition():FlxPoint
-	{
-		return new FlxPoint(_data.characters.gf.position[0], _data.characters.gf.position[1]);
-	}
-
-	public inline function getBoyfriendPosition():FlxPoint
-	{
-		return new FlxPoint(_data.characters.bf.position[0], _data.characters.bf.position[1]);
-	}
-
-	public inline function getDadPosition():FlxPoint
-	{
-		return new FlxPoint(_data.characters.dad.position[0], _data.characters.dad.position[1]);
-	}
-
-	/**
-	 * Retrieves a given character from the stage.
-	 */
-	public function getCharacter(id:String):BaseCharacter
-	{
-		return this.characters.get(id);
-	}
-
-	/**
-	 * Retrieve the Boyfriend character.
-	 * @param pop If true, the character will be removed from the stage as well.
-	 */
-	public function getBoyfriend(?pop:Bool = false):BaseCharacter
-	{
-		if (pop)
-		{
-			var boyfriend:BaseCharacter = getCharacter("bf");
-
-			// Remove the character from the stage.
-			this.remove(boyfriend);
-			this.characters.remove("bf");
-
-			return boyfriend;
-		}
-		else
-		{
-			return getCharacter('bf');
-		}
-	}
-
-	public function getGirlfriend():BaseCharacter
-	{
-		return getCharacter('gf');
-	}
-
-	public function getDad():BaseCharacter
-	{
-		return getCharacter('dad');
-	}
-
-	/**
-	 * Retrieve a specific prop by the name assigned in the JSON file.
-	 * @param name The name of the prop to retrieve.
-	 * @return The corresponding FlxSprite.
-	 */
-	public function getNamedProp(name:String):FlxSprite
-	{
-		return this.namedProps.get(name);
-	}
-
-	/**
-	 * Retrieve a list of all the asset paths required to load the stage.
-	 * Override this in a scripted class to ensure that all necessary assets are loaded!
-	 * 
-	 * @return An array of file names.
-	 */
-	public function fetchAssetPaths():Array<String>
-	{
-		var result:Array<String> = [];
-		for (dataProp in _data.props)
-		{
-			result.push(Paths.image(dataProp.assetPath));
-		}
-		return result;
-	}
-
-	/**
-	 * Dispatch an event to all the characters in the stage.
-	 * @param event The script event to dispatch.
-	 */
-	public function dispatchToCharacters(event:ScriptEvent):Void
-	{
-		for (characterId in characters.keys())
-		{
-			dispatchToCharacter(characterId, event);
-		}
-	}
-
-	/**
-	 * Dispatch an event to a specific character.
-	 * @param characterId The ID of the character to dispatch to.
-	 * @param event The script event to dispatch.
-	 */
-	public function dispatchToCharacter(characterId:String, event:ScriptEvent):Void
-	{
-		var character:BaseCharacter = getCharacter(characterId);
-		if (character != null)
-		{
-			ScriptEventDispatcher.callEvent(character, event);
-		}
-	}
-
-	/**
-	 * onDestroy gets called when the player is leaving the PlayState,
-	 * and is used to clean up any objects that need to be destroyed.
-	 */
-	public function onDestroy(event:ScriptEvent):Void
-	{
-		// Make sure to call kill() when returning a stage to cache,
-		// and destroy() only when performing a hard cache refresh.
-		kill();
-
-		for (prop in this.namedProps)
-		{
-			if (prop != null)
-			{
-				remove(prop);
-				prop.kill();
-				prop.destroy();
-			}
-		}
-		namedProps.clear();
-
-		for (char in this.characters)
-		{
-			if (char != null)
-			{
-				remove(char);
-				char.kill();
-				char.destroy();
-			}
-		}
-		characters.clear();
-
-		for (bopper in boppers)
-		{
-			if (bopper != null)
-			{
-				remove(bopper);
-				bopper.kill();
-				bopper.destroy();
-			}
-		}
-		boppers = [];
-
-		for (sprite in this.group)
-		{
-			if (sprite != null)
-			{
-				sprite.kill();
-				sprite.destroy();
-				remove(sprite);
-			}
-		}
-		group.clear();
-		if (debugIconGroup != null && debugIconGroup.group != null)
-		{
-			debugIconGroup.kill();
-		}
-		else
-		{
-			debugIconGroup = null;
-		}
-	}
-
-	/**
-	 * A function that gets called once per step in the song.
-	 * @param curStep The current step number.
-	 */
-	public function onStepHit(event:SongTimeScriptEvent):Void
-	{
-	}
-
-	/**
-	 * A function that gets called once per beat in the song (once every four steps).
-	 * @param curStep The current beat number.
-	 */
-	public function onBeatHit(event:SongTimeScriptEvent):Void
-	{
-		// Override me in your scripted stage to perform custom behavior!
-		// Make sure to call super.onBeatHit(curBeat) if you want to keep the boppers dancing.
-
-		for (bopper in boppers)
-		{
-			ScriptEventDispatcher.callEvent(bopper, event);
-		}
-	}
-
-	public function onUpdate(event:UpdateScriptEvent)
-	{
-		if (FlxG.keys.justPressed.F3)
-		{
-			debugIconGroup.visible = !debugIconGroup.visible;
-		}
-	}
-
-	public function onScriptEvent(event:ScriptEvent)
-	{
-	}
-
-	public function onPause(event:PauseScriptEvent)
-	{
-	}
-
-	public function onResume(event:ScriptEvent)
-	{
-	}
-
-	public function onSongStart(event:ScriptEvent)
-	{
-	}
-
-	public function onSongEnd(event:ScriptEvent)
-	{
-	}
-
-	public function onGameOver(event:ScriptEvent)
-	{
-	}
-
-	public function onCountdownStart(event:CountdownScriptEvent)
-	{
-	}
-
-	public function onCountdownStep(event:CountdownScriptEvent)
-	{
-	}
-
-	public function onCountdownEnd(event:CountdownScriptEvent)
-	{
-	}
-
-	public function onNoteHit(event:NoteScriptEvent)
-	{
-	}
-
-	public function onNoteMiss(event:NoteScriptEvent)
-	{
-	}
-
-	public function onNoteGhostMiss(event:GhostMissNoteScriptEvent)
-	{
-	}
-
-	public function onSongLoaded(event:SongLoadScriptEvent)
-	{
-	}
-
-	public function onSongRetry(event:ScriptEvent)
-	{
-	}
+  public final stageId:String;
+  public final stageName:String;
+
+  final _data:StageData;
+
+  public var camZoom:Float = 1.0;
+
+  var namedProps:Map<String, FlxSprite> = new Map<String, FlxSprite>();
+  var characters:Map<String, BaseCharacter> = new Map<String, BaseCharacter>();
+  var boppers:Array<Bopper> = new Array<Bopper>();
+
+  /**
+   * The Stage elements get initialized at the beginning of the game.
+   * They're used to cache the data needed to build the stage,
+   * then accessed and fleshed out when the stage needs to be built.
+   * 
+   * @param stageId 
+   */
+  public function new(stageId:String)
+  {
+    super();
+
+    this.stageId = stageId;
+    _data = StageDataParser.parseStageData(this.stageId);
+    if (_data == null)
+    {
+      throw 'Could not find stage data for stageId: $stageId';
+    }
+    else
+    {
+      this.stageName = _data.name;
+    }
+  }
+
+  /**
+   * Called when the player is moving into the PlayState where the song will be played.
+   */
+  public function onCreate(event:ScriptEvent):Void
+  {
+    buildStage();
+    this.refresh();
+
+    debugIconGroup = new FlxSpriteGroup();
+    debugIconGroup.visible = false;
+    debugIconGroup.zIndex = 1000000;
+    add(debugIconGroup);
+  }
+
+  public function resetStage():Void
+  {
+    // Reset positions of characters.
+    if (getBoyfriend() != null)
+    {
+      getBoyfriend().resetCharacter(false);
+    }
+    else
+    {
+      trace('STAGE RESET: No boyfriend found.');
+    }
+    if (getGirlfriend() != null)
+    {
+      getGirlfriend().resetCharacter(false);
+    }
+    if (getDad() != null)
+    {
+      getDad().resetCharacter(false);
+    }
+
+    // Reset positions of named props.
+    for (dataProp in _data.props)
+    {
+      // Fetch the prop.
+      var prop:FlxSprite = getNamedProp(dataProp.name);
+
+      if (prop != null)
+      {
+        // Reset the position.
+        prop.x = dataProp.position[0];
+        prop.y = dataProp.position[1];
+        prop.zIndex = dataProp.zIndex;
+      }
+    }
+
+    // We can assume unnamed props are not moving.
+  }
+
+  /**
+   * The default stage construction routine. Called when the stage is going to be played in.
+   * Instantiates each prop and adds it to the stage, while setting its parameters.
+   */
+  function buildStage()
+  {
+    trace('Building stage for display: ${this.stageId}');
+
+    this.camZoom = _data.cameraZoom;
+
+    this.debugIconGroup = new FlxSpriteGroup();
+
+    for (dataProp in _data.props)
+    {
+      trace('  Placing prop: ${dataProp.name} (${dataProp.assetPath})');
+
+      var isAnimated = dataProp.animations.length > 0;
+
+      var propSprite:FlxSprite;
+      if (dataProp.danceEvery != 0)
+      {
+        propSprite = new Bopper(dataProp.danceEvery);
+      }
+      else
+      {
+        propSprite = new FlxSprite();
+      }
+
+      if (isAnimated)
+      {
+        // Initalize sprite frames.
+        switch (dataProp.animType)
+        {
+          case "packer":
+            propSprite.frames = Paths.getPackerAtlas(dataProp.assetPath);
+          default: // "sparrow"
+            propSprite.frames = Paths.getSparrowAtlas(dataProp.assetPath);
+        }
+      }
+      else
+      {
+        // Initalize static sprite.
+        propSprite.loadGraphic(Paths.image(dataProp.assetPath));
+
+        // Disables calls to update() for a performance boost.
+        propSprite.active = false;
+      }
+
+      if (propSprite.frames == null || propSprite.frames.numFrames == 0)
+      {
+        trace('    ERROR: Could not build texture for prop.');
+        continue;
+      }
+
+      if (Std.isOfType(dataProp.scale, Array))
+      {
+        propSprite.scale.set(dataProp.scale[0], dataProp.scale[1]);
+      }
+      else
+      {
+        propSprite.scale.set(dataProp.scale);
+      }
+      propSprite.updateHitbox();
+
+      propSprite.x = dataProp.position[0];
+      propSprite.y = dataProp.position[1];
+
+      propSprite.alpha = dataProp.alpha;
+
+      // If pixel, disable antialiasing.
+      propSprite.antialiasing = !dataProp.isPixel;
+
+      propSprite.scrollFactor.x = dataProp.scroll[0];
+      propSprite.scrollFactor.y = dataProp.scroll[1];
+
+      propSprite.zIndex = dataProp.zIndex;
+
+      switch (dataProp.animType)
+      {
+        case "packer":
+          for (propAnim in dataProp.animations)
+          {
+            propSprite.animation.add(propAnim.name, propAnim.frameIndices);
+
+            if (Std.isOfType(propSprite, Bopper))
+            {
+              cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
+            }
+          }
+        default: // "sparrow"
+          FlxAnimationUtil.addAtlasAnimations(propSprite, dataProp.animations);
+          if (Std.isOfType(propSprite, Bopper))
+          {
+            for (propAnim in dataProp.animations)
+            {
+              cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
+            }
+          }
+      }
+
+      if (Std.isOfType(propSprite, Bopper))
+      {
+        for (propAnim in dataProp.animations)
+        {
+          cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
+        }
+        cast(propSprite, Bopper).originalPosition.x = dataProp.position[0];
+        cast(propSprite, Bopper).originalPosition.y = dataProp.position[1];
+      }
+
+      if (dataProp.startingAnimation != null)
+      {
+        propSprite.animation.play(dataProp.startingAnimation);
+      }
+
+      if (Std.isOfType(propSprite, Bopper))
+      {
+        addBopper(cast propSprite, dataProp.name);
+      }
+      else
+      {
+        addProp(propSprite, dataProp.name);
+      }
+    }
+  }
+
+  /**
+   * Add a sprite to the stage.
+   * @param prop The sprite to add.
+   * @param name (Optional) A unique name for the sprite.
+   *   You can call `getNamedProp(name)` to retrieve it later.
+   */
+  public function addProp(prop:FlxSprite, ?name:String = null)
+  {
+    if (name != null)
+    {
+      namedProps.set(name, prop);
+    }
+    this.add(prop);
+  }
+
+  /**
+   * Add a sprite to the stage which animates to the beat of the song.
+   */
+  public function addBopper(bopper:Bopper, ?name:String = null)
+  {
+    boppers.push(bopper);
+    this.addProp(bopper, name);
+  }
+
+  /**
+   * Refreshes the stage, by redoing the render order of all props.
+   * It does this based on the `zIndex` of each prop.
+   */
+  public function refresh()
+  {
+    sort(SortUtil.byZIndex, FlxSort.ASCENDING);
+  }
+
+  public function setShader(shader:FlxShader)
+  {
+    forEachAlive(function(prop:FlxSprite)
+    {
+      prop.shader = shader;
+    });
+  }
+
+  /**
+   * Adjusts the position and other properties of the soon-to-be child of this sprite group.
+   * Private helper to avoid duplicate code in `add()` and `insert()`.
+   *
+   * @param	Sprite	The sprite or sprite group that is about to be added or inserted into the group.
+   */
+  override function preAdd(Sprite:FlxSprite):Void
+  {
+    var sprite:FlxSprite = cast Sprite;
+    sprite.x += x;
+    sprite.y += y;
+    sprite.alpha *= alpha;
+    // Don't override scroll factors.
+    // sprite.scrollFactor.copyFrom(scrollFactor);
+    sprite.cameras = _cameras; // _cameras instead of cameras because get_cameras() will not return null
+
+    if (clipRect != null)
+      clipRectTransform(sprite, clipRect);
+  }
+
+  var debugIconGroup:FlxSpriteGroup;
+
+  /**
+   * Used by the PlayState to add a character to the stage.
+   */
+  public function addCharacter(character:BaseCharacter, charType:CharacterType)
+  {
+    if (character == null)
+      return;
+
+    #if debug
+    // Temporary marker that shows where the character's location is relative to.
+    // Should display at the stage position of the character (before any offsets).
+    // TODO: Make this a toggle? It's useful to turn on from time to time.
+    var debugIcon:FlxSprite = new FlxSprite(0, 0);
+    var debugIcon2:FlxSprite = new FlxSprite(0, 0);
+    debugIcon.makeGraphic(8, 8, 0xffff00ff);
+    debugIcon2.makeGraphic(8, 8, 0xff00ffff);
+    debugIcon.visible = true;
+    debugIcon2.visible = true;
+    debugIcon.zIndex = 1000000;
+    debugIcon2.zIndex = 1000000;
+    #end
+
+    // Apply position and z-index.
+    var charData:StageDataCharacter = null;
+    switch (charType)
+    {
+      case BF:
+        this.characters.set("bf", character);
+        charData = _data.characters.bf;
+        character.flipX = !character.getDataFlipX();
+        character.initHealthIcon(false);
+      case GF:
+        this.characters.set("gf", character);
+        charData = _data.characters.gf;
+        character.flipX = character.getDataFlipX();
+      case DAD:
+        this.characters.set("dad", character);
+        charData = _data.characters.dad;
+        character.flipX = character.getDataFlipX();
+        character.initHealthIcon(true);
+      default:
+        this.characters.set(character.characterId, character);
+    }
+
+    // Reset the character before adding it to the stage.
+    // This ensures positioning is based on the idle animation.
+    character.resetCharacter(true);
+
+    if (charData != null)
+    {
+      character.zIndex = charData.zIndex;
+
+      // Start with the per-stage character position.
+      // Subtracting the origin ensures characters are positioned relative to their feet.
+      // Subtracting the global offset allows positioning on a per-character basis.
+      character.x = charData.position[0] - character.characterOrigin.x + character.globalOffsets[0];
+      character.y = charData.position[1] - character.characterOrigin.y + character.globalOffsets[1];
+
+      character.originalPosition.x = character.x;
+      character.originalPosition.y = character.y;
+
+      character.cameraFocusPoint.x += charData.cameraOffsets[0];
+      character.cameraFocusPoint.y += charData.cameraOffsets[1];
+
+      #if debug
+      // Draw the debug icon at the character's feet.
+      if (charType == BF || charType == DAD)
+      {
+        debugIcon.x = charData.position[0];
+        debugIcon.y = charData.position[1];
+        debugIcon2.x = character.x;
+        debugIcon2.y = character.y;
+      }
+      #end
+    }
+
+    // Add the character to the scene.
+    this.add(character);
+
+    #if debug
+    debugIconGroup.add(debugIcon);
+    debugIconGroup.add(debugIcon2);
+    #end
+  }
+
+  public inline function getGirlfriendPosition():FlxPoint
+  {
+    return new FlxPoint(_data.characters.gf.position[0], _data.characters.gf.position[1]);
+  }
+
+  public inline function getBoyfriendPosition():FlxPoint
+  {
+    return new FlxPoint(_data.characters.bf.position[0], _data.characters.bf.position[1]);
+  }
+
+  public inline function getDadPosition():FlxPoint
+  {
+    return new FlxPoint(_data.characters.dad.position[0], _data.characters.dad.position[1]);
+  }
+
+  /**
+   * Retrieves a given character from the stage.
+   */
+  public function getCharacter(id:String):BaseCharacter
+  {
+    return this.characters.get(id);
+  }
+
+  /**
+   * Retrieve the Boyfriend character.
+   * @param pop If true, the character will be removed from the stage as well.
+   */
+  public function getBoyfriend(?pop:Bool = false):BaseCharacter
+  {
+    if (pop)
+    {
+      var boyfriend:BaseCharacter = getCharacter("bf");
+
+      // Remove the character from the stage.
+      this.remove(boyfriend);
+      this.characters.remove("bf");
+
+      return boyfriend;
+    }
+    else
+    {
+      return getCharacter('bf');
+    }
+  }
+
+  public function getGirlfriend():BaseCharacter
+  {
+    return getCharacter('gf');
+  }
+
+  public function getDad():BaseCharacter
+  {
+    return getCharacter('dad');
+  }
+
+  /**
+   * Retrieve a specific prop by the name assigned in the JSON file.
+   * @param name The name of the prop to retrieve.
+   * @return The corresponding FlxSprite.
+   */
+  public function getNamedProp(name:String):FlxSprite
+  {
+    return this.namedProps.get(name);
+  }
+
+  /**
+   * Retrieve a list of all the asset paths required to load the stage.
+   * Override this in a scripted class to ensure that all necessary assets are loaded!
+   * 
+   * @return An array of file names.
+   */
+  public function fetchAssetPaths():Array<String>
+  {
+    var result:Array<String> = [];
+    for (dataProp in _data.props)
+    {
+      result.push(Paths.image(dataProp.assetPath));
+    }
+    return result;
+  }
+
+  /**
+   * Dispatch an event to all the characters in the stage.
+   * @param event The script event to dispatch.
+   */
+  public function dispatchToCharacters(event:ScriptEvent):Void
+  {
+    for (characterId in characters.keys())
+    {
+      dispatchToCharacter(characterId, event);
+    }
+  }
+
+  /**
+   * Dispatch an event to a specific character.
+   * @param characterId The ID of the character to dispatch to.
+   * @param event The script event to dispatch.
+   */
+  public function dispatchToCharacter(characterId:String, event:ScriptEvent):Void
+  {
+    var character:BaseCharacter = getCharacter(characterId);
+    if (character != null)
+    {
+      ScriptEventDispatcher.callEvent(character, event);
+    }
+  }
+
+  /**
+   * onDestroy gets called when the player is leaving the PlayState,
+   * and is used to clean up any objects that need to be destroyed.
+   */
+  public function onDestroy(event:ScriptEvent):Void
+  {
+    // Make sure to call kill() when returning a stage to cache,
+    // and destroy() only when performing a hard cache refresh.
+    kill();
+
+    for (prop in this.namedProps)
+    {
+      if (prop != null)
+      {
+        remove(prop);
+        prop.kill();
+        prop.destroy();
+      }
+    }
+    namedProps.clear();
+
+    for (char in this.characters)
+    {
+      if (char != null)
+      {
+        remove(char);
+        char.kill();
+        char.destroy();
+      }
+    }
+    characters.clear();
+
+    for (bopper in boppers)
+    {
+      if (bopper != null)
+      {
+        remove(bopper);
+        bopper.kill();
+        bopper.destroy();
+      }
+    }
+    boppers = [];
+
+    for (sprite in this.group)
+    {
+      if (sprite != null)
+      {
+        sprite.kill();
+        sprite.destroy();
+        remove(sprite);
+      }
+    }
+    group.clear();
+    if (debugIconGroup != null && debugIconGroup.group != null)
+    {
+      debugIconGroup.kill();
+    }
+    else
+    {
+      debugIconGroup = null;
+    }
+  }
+
+  /**
+   * A function that gets called once per step in the song.
+   * @param curStep The current step number.
+   */
+  public function onStepHit(event:SongTimeScriptEvent):Void {}
+
+  /**
+   * A function that gets called once per beat in the song (once every four steps).
+   * @param curStep The current beat number.
+   */
+  public function onBeatHit(event:SongTimeScriptEvent):Void
+  {
+    // Override me in your scripted stage to perform custom behavior!
+    // Make sure to call super.onBeatHit(curBeat) if you want to keep the boppers dancing.
+
+    for (bopper in boppers)
+    {
+      ScriptEventDispatcher.callEvent(bopper, event);
+    }
+  }
+
+  public function onUpdate(event:UpdateScriptEvent)
+  {
+    if (FlxG.keys.justPressed.F3)
+    {
+      debugIconGroup.visible = !debugIconGroup.visible;
+    }
+  }
+
+  public function onScriptEvent(event:ScriptEvent) {}
+
+  public function onPause(event:PauseScriptEvent) {}
+
+  public function onResume(event:ScriptEvent) {}
+
+  public function onSongStart(event:ScriptEvent) {}
+
+  public function onSongEnd(event:ScriptEvent) {}
+
+  public function onGameOver(event:ScriptEvent) {}
+
+  public function onCountdownStart(event:CountdownScriptEvent) {}
+
+  public function onCountdownStep(event:CountdownScriptEvent) {}
+
+  public function onCountdownEnd(event:CountdownScriptEvent) {}
+
+  public function onNoteHit(event:NoteScriptEvent) {}
+
+  public function onNoteMiss(event:NoteScriptEvent) {}
+
+  public function onSongEvent(event:SongEventScriptEvent) {}
+
+  public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {}
+
+  public function onSongLoaded(event:SongLoadScriptEvent) {}
+
+  public function onSongRetry(event:ScriptEvent) {}
 }
diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx
index 380efc2fa..84f33fa80 100644
--- a/source/funkin/play/stage/StageData.hx
+++ b/source/funkin/play/stage/StageData.hx
@@ -13,507 +13,507 @@ import openfl.Assets;
  */
 class StageDataParser
 {
-	/**
-	 * 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 STAGE_DATA_VERSION:String = "1.0.0";
+  /**
+   * 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 STAGE_DATA_VERSION:String = "1.0.0";
 
-	/**
-	 * The current version rule check for the stage data format.
-	 */
-	public static final STAGE_DATA_VERSION_RULE:String = "1.0.x";
+  /**
+   * The current version rule check for the stage data format.
+   */
+  public static final STAGE_DATA_VERSION_RULE:String = "1.0.x";
 
-	static final stageCache:Map<String, Stage> = new Map<String, Stage>();
+  static final stageCache:Map<String, Stage> = new Map<String, Stage>();
 
-	static final DEFAULT_STAGE_ID = 'UNKNOWN';
+  static final DEFAULT_STAGE_ID = 'UNKNOWN';
 
-	/**
-	 * Parses and preloads the game's stage data and scripts when the game starts.
-	 * 
-	 * If you want to force stages to be reloaded, you can just call this function again.
-	 */
-	public static function loadStageCache():Void
-	{
-		// Clear any stages that are cached if there were any.
-		clearStageCache();
-		trace("[STAGEDATA] Loading stage cache...");
+  /**
+   * Parses and preloads the game's stage data and scripts when the game starts.
+   * 
+   * If you want to force stages to be reloaded, you can just call this function again.
+   */
+  public static function loadStageCache():Void
+  {
+    // Clear any stages that are cached if there were any.
+    clearStageCache();
+    trace("Loading stage cache...");
 
-		//
-		// SCRIPTED STAGES
-		//
-		var scriptedStageClassNames:Array<String> = ScriptedStage.listScriptClasses();
-		trace('  Instantiating ${scriptedStageClassNames.length} scripted stages...');
-		for (stageCls in scriptedStageClassNames)
-		{
-			var stage:Stage = ScriptedStage.init(stageCls, DEFAULT_STAGE_ID);
-			if (stage != null)
-			{
-				trace('    Loaded scripted stage: ${stage.stageName}');
-				// Disable the rendering logic for stage until it's loaded.
-				// Note that kill() =/= destroy()
-				stage.kill();
+    //
+    // SCRIPTED STAGES
+    //
+    var scriptedStageClassNames:Array<String> = ScriptedStage.listScriptClasses();
+    trace('  Instantiating ${scriptedStageClassNames.length} scripted stages...');
+    for (stageCls in scriptedStageClassNames)
+    {
+      var stage:Stage = ScriptedStage.init(stageCls, DEFAULT_STAGE_ID);
+      if (stage != null)
+      {
+        trace('    Loaded scripted stage: ${stage.stageName}');
+        // Disable the rendering logic for stage until it's loaded.
+        // Note that kill() =/= destroy()
+        stage.kill();
 
-				// Then store it.
-				stageCache.set(stage.stageId, stage);
-			}
-			else
-			{
-				trace('    Failed to instantiate scripted stage class: ${stageCls}');
-			}
-		}
+        // Then store it.
+        stageCache.set(stage.stageId, stage);
+      }
+      else
+      {
+        trace('    Failed to instantiate scripted stage class: ${stageCls}');
+      }
+    }
 
-		//
-		// UNSCRIPTED STAGES
-		//
-		var stageIdList:Array<String> = DataAssets.listDataFilesInPath('stages/');
-		var unscriptedStageIds:Array<String> = stageIdList.filter(function(stageId:String):Bool
-		{
-			return !stageCache.exists(stageId);
-		});
-		trace('  Instantiating ${unscriptedStageIds.length} non-scripted stages...');
-		for (stageId in unscriptedStageIds)
-		{
-			var stage:Stage;
-			try
-			{
-				stage = new Stage(stageId);
-				if (stage != null)
-				{
-					trace('    Loaded stage data: ${stage.stageName}');
-					stageCache.set(stageId, stage);
-				}
-			}
-			catch (e)
-			{
-				trace('    An error occurred while loading stage data: ${stageId}');
-				// Assume error was already logged.
-				continue;
-			}
-		}
+    //
+    // UNSCRIPTED STAGES
+    //
+    var stageIdList:Array<String> = DataAssets.listDataFilesInPath('stages/');
+    var unscriptedStageIds:Array<String> = stageIdList.filter(function(stageId:String):Bool
+    {
+      return !stageCache.exists(stageId);
+    });
+    trace('  Instantiating ${unscriptedStageIds.length} non-scripted stages...');
+    for (stageId in unscriptedStageIds)
+    {
+      var stage:Stage;
+      try
+      {
+        stage = new Stage(stageId);
+        if (stage != null)
+        {
+          trace('    Loaded stage data: ${stage.stageName}');
+          stageCache.set(stageId, stage);
+        }
+      }
+      catch (e)
+      {
+        trace('    An error occurred while loading stage data: ${stageId}');
+        // Assume error was already logged.
+        continue;
+      }
+    }
 
-		trace('  Successfully loaded ${Lambda.count(stageCache)} stages.');
-	}
+    trace('  Successfully loaded ${Lambda.count(stageCache)} stages.');
+  }
 
-	public static function fetchStage(stageId:String):Null<Stage>
-	{
-		if (stageCache.exists(stageId))
-		{
-			trace('[STAGEDATA] Successfully fetch stage: ${stageId}');
-			var stage:Stage = stageCache.get(stageId);
-			stage.revive();
-			return stage;
-		}
-		else
-		{
-			trace('[STAGEDATA] Failed to fetch stage, not found in cache: ${stageId}');
-			return null;
-		}
-	}
+  public static function fetchStage(stageId:String):Null<Stage>
+  {
+    if (stageCache.exists(stageId))
+    {
+      trace('Successfully fetch stage: ${stageId}');
+      var stage:Stage = stageCache.get(stageId);
+      stage.revive();
+      return stage;
+    }
+    else
+    {
+      trace('Failed to fetch stage, not found in cache: ${stageId}');
+      return null;
+    }
+  }
 
-	static function clearStageCache():Void
-	{
-		if (stageCache != null)
-		{
-			for (stage in stageCache)
-			{
-				stage.destroy();
-			}
-			stageCache.clear();
-		}
-	}
+  static function clearStageCache():Void
+  {
+    if (stageCache != null)
+    {
+      for (stage in stageCache)
+      {
+        stage.destroy();
+      }
+      stageCache.clear();
+    }
+  }
 
-	/**
-	 * Load a stage's JSON file, parse its data, and return it.
-	 * 
-	 * @param stageId The stage to load.
-	 * @return The stage data, or null if validation failed.
-	 */
-	public static function parseStageData(stageId:String):Null<StageData>
-	{
-		var rawJson:String = loadStageFile(stageId);
+  /**
+   * Load a stage's JSON file, parse its data, and return it.
+   * 
+   * @param stageId The stage to load.
+   * @return The stage data, or null if validation failed.
+   */
+  public static function parseStageData(stageId:String):Null<StageData>
+  {
+    var rawJson:String = loadStageFile(stageId);
 
-		var stageData:StageData = migrateStageData(rawJson, stageId);
+    var stageData:StageData = migrateStageData(rawJson, stageId);
 
-		return validateStageData(stageId, stageData);
-	}
+    return validateStageData(stageId, stageData);
+  }
 
-	public static function listStageIds():Array<String>
-	{
-		return stageCache.keys().array();
-	}
+  public static function listStageIds():Array<String>
+  {
+    return stageCache.keys().array();
+  }
 
-	static function loadStageFile(stagePath:String):String
-	{
-		var stageFilePath:String = Paths.json('stages/${stagePath}');
-		var rawJson = Assets.getText(stageFilePath).trim();
+  static function loadStageFile(stagePath:String):String
+  {
+    var stageFilePath:String = Paths.json('stages/${stagePath}');
+    var rawJson = Assets.getText(stageFilePath).trim();
 
-		while (!rawJson.endsWith("}"))
-		{
-			rawJson = rawJson.substr(0, rawJson.length - 1);
-		}
+    while (!rawJson.endsWith("}"))
+    {
+      rawJson = rawJson.substr(0, rawJson.length - 1);
+    }
 
-		return rawJson;
-	}
+    return rawJson;
+  }
 
-	static function migrateStageData(rawJson:String, stageId:String)
-	{
-		// If you update the stage data format in a breaking way,
-		// handle migration here by checking the `version` value.
+  static function migrateStageData(rawJson:String, stageId:String)
+  {
+    // If you update the stage data format in a breaking way,
+    // handle migration here by checking the `version` value.
 
-		try
-		{
-			var stageData:StageData = cast Json.parse(rawJson);
-			return stageData;
-		}
-		catch (e)
-		{
-			trace('  Error parsing data for stage: ${stageId}');
-			trace('    ${e}');
-			return null;
-		}
-	}
+    try
+    {
+      var stageData:StageData = cast Json.parse(rawJson);
+      return stageData;
+    }
+    catch (e)
+    {
+      trace('  Error parsing data for stage: ${stageId}');
+      trace('    ${e}');
+      return null;
+    }
+  }
 
-	static final DEFAULT_ANIMTYPE:String = "sparrow";
-	static final DEFAULT_CAMERAZOOM:Float = 1.0;
-	static final DEFAULT_DANCEEVERY:Int = 0;
-	static final DEFAULT_ISPIXEL:Bool = false;
-	static final DEFAULT_NAME:String = "Untitled Stage";
-	static final DEFAULT_OFFSETS:Array<Float> = [0, 0];
-	static final DEFAULT_CAMERA_OFFSETS_BF:Array<Float> = [-100, -100];
-	static final DEFAULT_CAMERA_OFFSETS_DAD:Array<Float> = [150, -100];
-	static final DEFAULT_POSITION:Array<Float> = [0, 0];
-	static final DEFAULT_SCALE:Float = 1.0;
-	static final DEFAULT_ALPHA:Float = 1.0;
-	static final DEFAULT_SCROLL:Array<Float> = [0, 0];
-	static final DEFAULT_ZINDEX:Int = 0;
+  static final DEFAULT_ANIMTYPE:String = "sparrow";
+  static final DEFAULT_CAMERAZOOM:Float = 1.0;
+  static final DEFAULT_DANCEEVERY:Int = 0;
+  static final DEFAULT_ISPIXEL:Bool = false;
+  static final DEFAULT_NAME:String = "Untitled Stage";
+  static final DEFAULT_OFFSETS:Array<Float> = [0, 0];
+  static final DEFAULT_CAMERA_OFFSETS_BF:Array<Float> = [-100, -100];
+  static final DEFAULT_CAMERA_OFFSETS_DAD:Array<Float> = [150, -100];
+  static final DEFAULT_POSITION:Array<Float> = [0, 0];
+  static final DEFAULT_SCALE:Float = 1.0;
+  static final DEFAULT_ALPHA:Float = 1.0;
+  static final DEFAULT_SCROLL:Array<Float> = [0, 0];
+  static final DEFAULT_ZINDEX:Int = 0;
 
-	static final DEFAULT_CHARACTER_DATA:StageDataCharacter = {
-		zIndex: DEFAULT_ZINDEX,
-		position: DEFAULT_POSITION,
-		cameraOffsets: DEFAULT_OFFSETS,
-	}
+  static final DEFAULT_CHARACTER_DATA:StageDataCharacter = {
+    zIndex: DEFAULT_ZINDEX,
+    position: DEFAULT_POSITION,
+    cameraOffsets: DEFAULT_OFFSETS,
+  }
 
-	/**
-	 * Set unspecified parameters to their defaults.
-	 * If the parameter is mandatory, print an error message.
-	 * @param id 
-	 * @param input 
-	 * @return The validated stage data
-	 */
-	static function validateStageData(id:String, input:StageData):Null<StageData>
-	{
-		if (input == null)
-		{
-			trace('[STAGEDATA] ERROR: Could not parse stage data for "${id}".');
-			return null;
-		}
+  /**
+   * Set unspecified parameters to their defaults.
+   * If the parameter is mandatory, print an error message.
+   * @param id 
+   * @param input 
+   * @return The validated stage data
+   */
+  static function validateStageData(id:String, input:StageData):Null<StageData>
+  {
+    if (input == null)
+    {
+      trace('ERROR: Could not parse stage data for "${id}".');
+      return null;
+    }
 
-		if (input.version == null)
-		{
-			trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing version');
-			return null;
-		}
+    if (input.version == null)
+    {
+      trace('ERROR: Could not load stage data for "$id": missing version');
+      return null;
+    }
 
-		if (!VersionUtil.validateVersion(input.version, STAGE_DATA_VERSION_RULE))
-		{
-			trace('[STAGEDATA] ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})');
-			return null;
-		}
+    if (!VersionUtil.validateVersion(input.version, STAGE_DATA_VERSION_RULE))
+    {
+      trace('ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})');
+      return null;
+    }
 
-		if (input.name == null)
-		{
-			trace('[STAGEDATA] WARN: Stage data for "$id" missing name');
-			input.name = DEFAULT_NAME;
-		}
+    if (input.name == null)
+    {
+      trace('WARN: Stage data for "$id" missing name');
+      input.name = DEFAULT_NAME;
+    }
 
-		if (input.cameraZoom == null)
-		{
-			input.cameraZoom = DEFAULT_CAMERAZOOM;
-		}
+    if (input.cameraZoom == null)
+    {
+      input.cameraZoom = DEFAULT_CAMERAZOOM;
+    }
 
-		if (input.props == null)
-		{
-			input.props = [];
-		}
+    if (input.props == null)
+    {
+      input.props = [];
+    }
 
-		for (inputProp in input.props)
-		{
-			// It's fine for inputProp.name to be null
+    for (inputProp in input.props)
+    {
+      // It's fine for inputProp.name to be null
 
-			if (inputProp.assetPath == null)
-			{
-				trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing assetPath for prop "${inputProp.name}"');
-				return null;
-			}
+      if (inputProp.assetPath == null)
+      {
+        trace('ERROR: Could not load stage data for "$id": missing assetPath for prop "${inputProp.name}"');
+        return null;
+      }
 
-			if (inputProp.position == null)
-			{
-				inputProp.position = DEFAULT_POSITION;
-			}
+      if (inputProp.position == null)
+      {
+        inputProp.position = DEFAULT_POSITION;
+      }
 
-			if (inputProp.zIndex == null)
-			{
-				inputProp.zIndex = DEFAULT_ZINDEX;
-			}
+      if (inputProp.zIndex == null)
+      {
+        inputProp.zIndex = DEFAULT_ZINDEX;
+      }
 
-			if (inputProp.isPixel == null)
-			{
-				inputProp.isPixel = DEFAULT_ISPIXEL;
-			}
+      if (inputProp.isPixel == null)
+      {
+        inputProp.isPixel = DEFAULT_ISPIXEL;
+      }
 
-			if (inputProp.danceEvery == null)
-			{
-				inputProp.danceEvery = DEFAULT_DANCEEVERY;
-			}
+      if (inputProp.danceEvery == null)
+      {
+        inputProp.danceEvery = DEFAULT_DANCEEVERY;
+      }
 
-			if (inputProp.scale == null)
-			{
-				inputProp.scale = DEFAULT_SCALE;
-			}
+      if (inputProp.scale == null)
+      {
+        inputProp.scale = DEFAULT_SCALE;
+      }
 
-			if (inputProp.animType == null)
-			{
-				inputProp.animType = DEFAULT_ANIMTYPE;
-			}
+      if (inputProp.animType == null)
+      {
+        inputProp.animType = DEFAULT_ANIMTYPE;
+      }
 
-			if (Std.isOfType(inputProp.scale, Float))
-			{
-				inputProp.scale = [inputProp.scale, inputProp.scale];
-			}
+      if (Std.isOfType(inputProp.scale, Float))
+      {
+        inputProp.scale = [inputProp.scale, inputProp.scale];
+      }
 
-			if (inputProp.scroll == null)
-			{
-				inputProp.scroll = DEFAULT_SCROLL;
-			}
+      if (inputProp.scroll == null)
+      {
+        inputProp.scroll = DEFAULT_SCROLL;
+      }
 
-			if (inputProp.alpha == null)
-			{
-				inputProp.alpha = DEFAULT_ALPHA;
-			}
+      if (inputProp.alpha == null)
+      {
+        inputProp.alpha = DEFAULT_ALPHA;
+      }
 
-			if (Std.isOfType(inputProp.scroll, Float))
-			{
-				inputProp.scroll = [inputProp.scroll, inputProp.scroll];
-			}
+      if (Std.isOfType(inputProp.scroll, Float))
+      {
+        inputProp.scroll = [inputProp.scroll, inputProp.scroll];
+      }
 
-			if (inputProp.animations == null)
-			{
-				inputProp.animations = [];
-			}
+      if (inputProp.animations == null)
+      {
+        inputProp.animations = [];
+      }
 
-			if (inputProp.animations.length == 0 && inputProp.startingAnimation != null)
-			{
-				trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing animations for prop "${inputProp.name}"');
-				return null;
-			}
+      if (inputProp.animations.length == 0 && inputProp.startingAnimation != null)
+      {
+        trace('ERROR: Could not load stage data for "$id": missing animations for prop "${inputProp.name}"');
+        return null;
+      }
 
-			for (inputAnimation in inputProp.animations)
-			{
-				if (inputAnimation.name == null)
-				{
-					trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing animation name for prop "${inputProp.name}"');
-					return null;
-				}
+      for (inputAnimation in inputProp.animations)
+      {
+        if (inputAnimation.name == null)
+        {
+          trace('ERROR: Could not load stage data for "$id": missing animation name for prop "${inputProp.name}"');
+          return null;
+        }
 
-				if (inputAnimation.frameRate == null)
-				{
-					inputAnimation.frameRate = 24;
-				}
+        if (inputAnimation.frameRate == null)
+        {
+          inputAnimation.frameRate = 24;
+        }
 
-				if (inputAnimation.offsets == null)
-				{
-					inputAnimation.offsets = DEFAULT_OFFSETS;
-				}
+        if (inputAnimation.offsets == null)
+        {
+          inputAnimation.offsets = DEFAULT_OFFSETS;
+        }
 
-				if (inputAnimation.looped == null)
-				{
-					inputAnimation.looped = true;
-				}
+        if (inputAnimation.looped == null)
+        {
+          inputAnimation.looped = true;
+        }
 
-				if (inputAnimation.flipX == null)
-				{
-					inputAnimation.flipX = false;
-				}
+        if (inputAnimation.flipX == null)
+        {
+          inputAnimation.flipX = false;
+        }
 
-				if (inputAnimation.flipY == null)
-				{
-					inputAnimation.flipY = false;
-				}
-			}
-		}
+        if (inputAnimation.flipY == null)
+        {
+          inputAnimation.flipY = false;
+        }
+      }
+    }
 
-		if (input.characters == null)
-		{
-			trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing characters');
-			return null;
-		}
+    if (input.characters == null)
+    {
+      trace('ERROR: Could not load stage data for "$id": missing characters');
+      return null;
+    }
 
-		if (input.characters.bf == null)
-		{
-			input.characters.bf = DEFAULT_CHARACTER_DATA;
-			input.characters.bf.cameraOffsets = DEFAULT_CAMERA_OFFSETS_BF;
-		}
-		if (input.characters.dad == null)
-		{
-			input.characters.dad = DEFAULT_CHARACTER_DATA;
-			input.characters.dad.cameraOffsets = DEFAULT_CAMERA_OFFSETS_DAD;
-		}
-		if (input.characters.gf == null)
-		{
-			input.characters.gf = DEFAULT_CHARACTER_DATA;
-		}
+    if (input.characters.bf == null)
+    {
+      input.characters.bf = DEFAULT_CHARACTER_DATA;
+      input.characters.bf.cameraOffsets = DEFAULT_CAMERA_OFFSETS_BF;
+    }
+    if (input.characters.dad == null)
+    {
+      input.characters.dad = DEFAULT_CHARACTER_DATA;
+      input.characters.dad.cameraOffsets = DEFAULT_CAMERA_OFFSETS_DAD;
+    }
+    if (input.characters.gf == null)
+    {
+      input.characters.gf = DEFAULT_CHARACTER_DATA;
+    }
 
-		for (inputCharacter in [input.characters.bf, input.characters.dad, input.characters.gf])
-		{
-			if (inputCharacter.zIndex == null)
-			{
-				inputCharacter.zIndex = 0;
-			}
-			if (inputCharacter.position == null || inputCharacter.position.length != 2)
-			{
-				inputCharacter.position = [0, 0];
-			}
-			if (inputCharacter.cameraOffsets == null || inputCharacter.cameraOffsets.length != 2)
-			{
-				if (inputCharacter == input.characters.bf)
-					inputCharacter.cameraOffsets = DEFAULT_CAMERA_OFFSETS_BF;
-				else if (inputCharacter == input.characters.dad)
-					inputCharacter.cameraOffsets = DEFAULT_CAMERA_OFFSETS_DAD;
-				else
-				{
-					inputCharacter.cameraOffsets = [0, 0];
-				}
-			}
-		}
+    for (inputCharacter in [input.characters.bf, input.characters.dad, input.characters.gf])
+    {
+      if (inputCharacter.zIndex == null)
+      {
+        inputCharacter.zIndex = 0;
+      }
+      if (inputCharacter.position == null || inputCharacter.position.length != 2)
+      {
+        inputCharacter.position = [0, 0];
+      }
+      if (inputCharacter.cameraOffsets == null || inputCharacter.cameraOffsets.length != 2)
+      {
+        if (inputCharacter == input.characters.bf)
+          inputCharacter.cameraOffsets = DEFAULT_CAMERA_OFFSETS_BF;
+        else if (inputCharacter == input.characters.dad)
+          inputCharacter.cameraOffsets = DEFAULT_CAMERA_OFFSETS_DAD;
+        else
+        {
+          inputCharacter.cameraOffsets = [0, 0];
+        }
+      }
+    }
 
-		// All good!
-		return input;
-	}
+    // All good!
+    return input;
+  }
 }
 
 typedef StageData =
 {
-	/**
-	 * The sematic version number of the stage data JSON format.
-	 * Supports fancy comparisons like NPM does it's neat.
-	 */
-	var version:String;
+  /**
+   * The sematic version number of the stage data JSON format.
+   * Supports fancy comparisons like NPM does it's neat.
+   */
+  var version:String;
 
-	var name:String;
-	var cameraZoom:Null<Float>;
-	var props:Array<StageDataProp>;
-	var characters:
-		{
-			bf:StageDataCharacter,
-			dad:StageDataCharacter,
-			gf:StageDataCharacter,
-		};
+  var name:String;
+  var cameraZoom:Null<Float>;
+  var props:Array<StageDataProp>;
+  var characters:
+    {
+      bf:StageDataCharacter,
+      dad:StageDataCharacter,
+      gf:StageDataCharacter,
+    };
 };
 
 typedef StageDataProp =
 {
-	/**
-	 * The name of the prop for later lookup by scripts.
-	 * Optional; if unspecified, the prop can't be referenced by scripts.
-	 */
-	var name:String;
+  /**
+   * The name of the prop for later lookup by scripts.
+   * Optional; if unspecified, the prop can't be referenced by scripts.
+   */
+  var name:String;
 
-	/**
-	 * The asset used to display the prop.
-	 */
-	var assetPath:String;
+  /**
+   * The asset used to display the prop.
+   */
+  var assetPath:String;
 
-	/**
-	 * The position of the prop as an [x, y] array of two floats.
-	 */
-	var position:Array<Float>;
+  /**
+   * The position of the prop as an [x, y] array of two floats.
+   */
+  var position:Array<Float>;
 
-	/**
-	 * A number determining the stack order of the prop, relative to other props and the characters in the stage.
-	 * Props with lower numbers render below those with higher numbers.
-	 * This is just like CSS, it isn't hard.
-	 * @default 0
-	 */
-	var zIndex:Null<Int>;
+  /**
+   * A number determining the stack order of the prop, relative to other props and the characters in the stage.
+   * Props with lower numbers render below those with higher numbers.
+   * This is just like CSS, it isn't hard.
+   * @default 0
+   */
+  var zIndex:Null<Int>;
 
-	/**
-	 * If set to true, anti-aliasing will be forcibly disabled on the sprite.
-	 * This prevents blurry images on pixel-art levels.
-	 * @default false
-	 */
-	var isPixel:Null<Bool>;
+  /**
+   * If set to true, anti-aliasing will be forcibly disabled on the sprite.
+   * This prevents blurry images on pixel-art levels.
+   * @default false
+   */
+  var isPixel:Null<Bool>;
 
-	/**
-	 * Either the scale of the prop as a float, or the [w, h] scale as an array of two floats.
-	 * Pro tip: On pixel-art levels, save the sprite small and set this value to 6 or so to save memory.
-	 * @default 1
-	 */
-	var scale:OneOfTwo<Float, Array<Float>>;
+  /**
+   * Either the scale of the prop as a float, or the [w, h] scale as an array of two floats.
+   * Pro tip: On pixel-art levels, save the sprite small and set this value to 6 or so to save memory.
+   * @default 1
+   */
+  var scale:OneOfTwo<Float, Array<Float>>;
 
-	/**
-	 * The alpha of the prop, as a float.
-	 * @default 1.0
-	 */
-	var alpha:Null<Float>;
+  /**
+   * The alpha of the prop, as a float.
+   * @default 1.0
+   */
+  var alpha:Null<Float>;
 
-	/**
-	 * If not zero, this prop will play an animation every X beats of the song.
-	 * This requires animations to be defined. If `danceLeft` and `danceRight` are defined,
-	 * they will alternated between, otherwise the `idle` animation will be used.
-	 * 
-	 * @default 0
-	 */
-	var danceEvery:Null<Int>;
+  /**
+   * If not zero, this prop will play an animation every X beats of the song.
+   * This requires animations to be defined. If `danceLeft` and `danceRight` are defined,
+   * they will alternated between, otherwise the `idle` animation will be used.
+   * 
+   * @default 0
+   */
+  var danceEvery:Null<Int>;
 
-	/**
-	 * How much the prop scrolls relative to the camera. Used to create a parallax effect.
-	 * Represented as a float or as an [x, y] array of two floats.
-	 * [1, 1] means the prop moves 1:1 with the camera.
-	 * [0.5, 0.5] means the prop half as much as the camera.
-	 * [0, 0] means the prop is not moved.
-	 * @default [0, 0]
-	 */
-	var scroll:OneOfTwo<Float, Array<Float>>;
+  /**
+   * How much the prop scrolls relative to the camera. Used to create a parallax effect.
+   * Represented as a float or as an [x, y] array of two floats.
+   * [1, 1] means the prop moves 1:1 with the camera.
+   * [0.5, 0.5] means the prop half as much as the camera.
+   * [0, 0] means the prop is not moved.
+   * @default [0, 0]
+   */
+  var scroll:OneOfTwo<Float, Array<Float>>;
 
-	/**
-	 * An optional array of animations which the prop can play.
-	 * @default Prop has no animations.
-	 */
-	var animations:Array<AnimationData>;
+  /**
+   * An optional array of animations which the prop can play.
+   * @default Prop has no animations.
+   */
+  var animations:Array<AnimationData>;
 
-	/**
-	 * If animations are used, this is the name of the animation to play first.
-	 * @default Don't play an animation.
-	 */
-	var startingAnimation:String;
+  /**
+   * If animations are used, this is the name of the animation to play first.
+   * @default Don't play an animation.
+   */
+  var startingAnimation:String;
 
-	/**
-	 * The animation type to use.
-	 * Options: "sparrow", "packer"
-	 * @default "sparrow"
-	 */
-	var animType:String;
+  /**
+   * The animation type to use.
+   * Options: "sparrow", "packer"
+   * @default "sparrow"
+   */
+  var animType:String;
 };
 
 typedef StageDataCharacter =
 {
-	/**
-	 * A number determining the stack order of the character, relative to props and other characters in the stage.
-	 * Again, just like CSS.
-	 * @default 0
-	 */
-	zIndex:Null<Int>,
+  /**
+   * A number determining the stack order of the character, relative to props and other characters in the stage.
+   * Again, just like CSS.
+   * @default 0
+   */
+  zIndex:Null<Int>,
 
-	/**
-	 * The position to render the character at.
-	 */
-	position:Array<Float>,
+  /**
+   * The position to render the character at.
+   */
+  position:Array<Float>,
 
-	/**
-	 * The camera offsets to apply when focusing on the character on this stage.
-	 * @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF
-	 */
-	cameraOffsets:Array<Float>,
+  /**
+   * The camera offsets to apply when focusing on the character on this stage.
+   * @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF
+   */
+  cameraOffsets:Array<Float>,
 };
diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
index 64fab3866..3e3f3774f 100644
--- a/source/funkin/ui/debug/charting/ChartEditorCommand.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
@@ -55,11 +55,12 @@ class AddNotesCommand implements ChartEditorCommand
 
 		if (appendToSelection)
 		{
-			state.currentSelection = state.currentSelection.concat(notes);
+			state.currentNoteSelection = state.currentNoteSelection.concat(notes);
 		}
 		else
 		{
-			state.currentSelection = notes;
+			state.currentNoteSelection = notes;
+			state.currentEventSelection = [];
 		}
 
 		state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
@@ -74,7 +75,8 @@ class AddNotesCommand implements ChartEditorCommand
 	public function undo(state:ChartEditorState):Void
 	{
 		state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
-		state.currentSelection = [];
+		state.currentNoteSelection = [];
+		state.currentEventSelection = [];
 		state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
 
 		state.saveDataDirty = true;
@@ -108,7 +110,8 @@ class RemoveNotesCommand implements ChartEditorCommand
 	public function execute(state:ChartEditorState):Void
 	{
 		state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
-		state.currentSelection = [];
+		state.currentNoteSelection = [];
+		state.currentEventSelection = [];
 		state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
 
 		state.saveDataDirty = true;
@@ -124,7 +127,8 @@ class RemoveNotesCommand implements ChartEditorCommand
 		{
 			state.currentSongChartNoteData.push(note);
 		}
-		state.currentSelection = notes;
+		state.currentNoteSelection = notes;
+		state.currentEventSelection = [];
 		state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
 
 		state.saveDataDirty = true;
@@ -146,6 +150,241 @@ class RemoveNotesCommand implements ChartEditorCommand
 	}
 }
 
+/**
+ * Appends one or more items to the selection.
+ */
+class SelectItemsCommand implements ChartEditorCommand
+{
+	private var notes:Array<SongNoteData>;
+	private var events:Array<SongEventData>;
+
+	public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
+	{
+		this.notes = notes;
+		this.events = events;
+	}
+
+	public function execute(state:ChartEditorState):Void
+	{
+		for (note in this.notes)
+		{
+			state.currentNoteSelection.push(note);
+		}
+
+		for (event in this.events)
+		{
+			state.currentEventSelection.push(event);
+		}
+
+		state.noteDisplayDirty = true;
+		state.notePreviewDirty = true;
+	}
+
+	public function undo(state:ChartEditorState):Void
+	{
+		state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
+		state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
+
+		state.noteDisplayDirty = true;
+		state.notePreviewDirty = true;
+	}
+
+	public function toString():String
+	{
+		var len:Int = notes.length + events.length;
+
+		if (notes.length == 0)
+		{
+			if (events.length == 1)
+			{
+				return 'Select Event';
+			}
+			else
+			{
+				return 'Select ${events.length} Events';
+			}
+		}
+		else if (events.length == 0)
+		{
+			if (notes.length == 1)
+			{
+				return 'Select Note';
+			}
+			else
+			{
+				return 'Select ${notes.length} Notes';
+			}
+		}
+
+		return 'Select ${len} Items';
+	}
+}
+
+class AddEventsCommand implements ChartEditorCommand
+{
+	private var events:Array<SongEventData>;
+	private var appendToSelection:Bool;
+
+	public function new(events:Array<SongEventData>, ?appendToSelection:Bool = false)
+	{
+		this.events = events;
+		this.appendToSelection = appendToSelection;
+	}
+
+	public function execute(state:ChartEditorState):Void
+	{
+		for (event in events)
+		{
+			state.currentSongChartEventData.push(event);
+		}
+
+		if (appendToSelection)
+		{
+			state.currentEventSelection = state.currentEventSelection.concat(events);
+		}
+		else
+		{
+			state.currentNoteSelection = [];
+			state.currentEventSelection = events;
+		}
+
+		state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
+
+		state.saveDataDirty = true;
+		state.noteDisplayDirty = true;
+		state.notePreviewDirty = true;
+
+		state.sortChartData();
+	}
+
+	public function undo(state:ChartEditorState):Void
+	{
+		state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
+
+		state.currentNoteSelection = [];
+		state.currentEventSelection = [];
+
+		state.saveDataDirty = true;
+		state.noteDisplayDirty = true;
+		state.notePreviewDirty = true;
+
+		state.sortChartData();
+	}
+
+	public function toString():String
+	{
+		var len:Int = events.length;
+		return 'Add $len Events';
+	}
+}
+
+class RemoveEventsCommand implements ChartEditorCommand
+{
+	private var events:Array<SongEventData>;
+
+	public function new(events:Array<SongEventData>)
+	{
+		this.events = events;
+	}
+
+	public function execute(state:ChartEditorState):Void
+	{
+		state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
+		state.currentEventSelection = [];
+		state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
+
+		state.saveDataDirty = true;
+		state.noteDisplayDirty = true;
+		state.notePreviewDirty = true;
+
+		state.sortChartData();
+	}
+
+	public function undo(state:ChartEditorState):Void
+	{
+		for (event in events)
+		{
+			state.currentSongChartEventData.push(event);
+		}
+		state.currentEventSelection = events;
+		state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
+
+		state.saveDataDirty = true;
+		state.noteDisplayDirty = true;
+		state.notePreviewDirty = true;
+
+		state.sortChartData();
+	}
+
+	public function toString():String
+	{
+		if (events.length == 1 && events[0] != null)
+		{
+			return 'Remove Event';
+		}
+
+		return 'Remove ${events.length} Events';
+	}
+}
+
+class RemoveItemsCommand implements ChartEditorCommand
+{
+	private var notes:Array<SongNoteData>;
+	private var events:Array<SongEventData>;
+
+	public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
+	{
+		this.notes = notes;
+		this.events = events;
+	}
+
+	public function execute(state:ChartEditorState):Void
+	{
+		state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
+		state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
+
+		state.currentNoteSelection = [];
+		state.currentEventSelection = [];
+
+		state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
+
+		state.saveDataDirty = true;
+		state.noteDisplayDirty = true;
+		state.notePreviewDirty = true;
+
+		state.sortChartData();
+	}
+
+	public function undo(state:ChartEditorState):Void
+	{
+		for (note in notes)
+		{
+			state.currentSongChartNoteData.push(note);
+		}
+
+		for (event in events)
+		{
+			state.currentSongChartEventData.push(event);
+		}
+
+		state.currentNoteSelection = notes;
+		state.currentEventSelection = events;
+
+		state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
+
+		state.saveDataDirty = true;
+		state.noteDisplayDirty = true;
+		state.notePreviewDirty = true;
+
+		state.sortChartData();
+	}
+
+	public function toString():String
+	{
+		return 'Remove ${notes.length + events.length} Items';
+	}
+}
+
 class SwitchDifficultyCommand implements ChartEditorCommand
 {
 	private var prevDifficulty:String;
@@ -185,61 +424,21 @@ class SwitchDifficultyCommand implements ChartEditorCommand
 	}
 }
 
-/**
- * Adds one or more notes to the selection.
- */
-class SelectNotesCommand implements ChartEditorCommand
+class DeselectItemsCommand implements ChartEditorCommand
 {
 	private var notes:Array<SongNoteData>;
+	private var events:Array<SongEventData>;
 
-	public function new(notes:Array<SongNoteData>)
+	public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
 	{
 		this.notes = notes;
+		this.events = events;
 	}
 
 	public function execute(state:ChartEditorState):Void
 	{
-		for (note in this.notes)
-		{
-			state.currentSelection.push(note);
-		}
-
-		state.noteDisplayDirty = true;
-		state.notePreviewDirty = true;
-	}
-
-	public function undo(state:ChartEditorState):Void
-	{
-		state.currentSelection = SongDataUtils.subtractNotes(state.currentSelection, this.notes);
-
-		state.noteDisplayDirty = true;
-		state.notePreviewDirty = true;
-	}
-
-	public function toString():String
-	{
-		if (notes.length == 1)
-		{
-			var dir:String = notes[0].getDirectionName();
-			return 'Select $dir Note';
-		}
-
-		return 'Select ${notes.length} Notes';
-	}
-}
-
-class DeselectNotesCommand implements ChartEditorCommand
-{
-	private var notes:Array<SongNoteData>;
-
-	public function new(notes:Array<SongNoteData>)
-	{
-		this.notes = notes;
-	}
-
-	public function execute(state:ChartEditorState):Void
-	{
-		state.currentSelection = SongDataUtils.subtractNotes(state.currentSelection, this.notes);
+		state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
+		state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
 
 		state.noteDisplayDirty = true;
 		state.notePreviewDirty = true;
@@ -249,7 +448,12 @@ class DeselectNotesCommand implements ChartEditorCommand
 	{
 		for (note in this.notes)
 		{
-			state.currentSelection.push(note);
+			state.currentNoteSelection.push(note);
+		}
+
+		for (event in this.events)
+		{
+			state.currentEventSelection.push(event);
 		}
 
 		state.noteDisplayDirty = true;
@@ -258,13 +462,15 @@ class DeselectNotesCommand implements ChartEditorCommand
 
 	public function toString():String
 	{
-		if (notes.length == 1)
+		var noteCount = notes.length + events.length;
+
+		if (noteCount == 1)
 		{
 			var dir:String = notes[0].getDirectionName();
-			return 'Deselect $dir Note';
+			return 'Deselect $dir Items';
 		}
 
-		return 'Deselect ${notes.length} Notes';
+		return 'Deselect ${noteCount} Items';
 	}
 }
 
@@ -272,20 +478,26 @@ class DeselectNotesCommand implements ChartEditorCommand
  * Sets the selection rather than appends it.
  * Deselects any notes that are not in the new selection.
  */
-class SetNoteSelectionCommand implements ChartEditorCommand
+class SetItemSelectionCommand implements ChartEditorCommand
 {
 	private var notes:Array<SongNoteData>;
-	private var previousSelection:Array<SongNoteData>;
+	private var events:Array<SongEventData>;
+	private var previousNoteSelection:Array<SongNoteData>;
+	private var previousEventSelection:Array<SongEventData>;
 
-	public function new(notes:Array<SongNoteData>, ?previousSelection:Array<SongNoteData>)
+	public function new(notes:Array<SongNoteData>, events:Array<SongEventData>, previousNoteSelection:Array<SongNoteData>,
+			previousEventSelection:Array<SongEventData>)
 	{
 		this.notes = notes;
-		this.previousSelection = previousSelection == null ? [] : previousSelection;
+		this.events = events;
+		this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
+		this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
 	}
 
 	public function execute(state:ChartEditorState):Void
 	{
-		state.currentSelection = notes;
+		state.currentNoteSelection = notes;
+		state.currentEventSelection = events;
 
 		state.noteDisplayDirty = true;
 		state.notePreviewDirty = true;
@@ -293,7 +505,8 @@ class SetNoteSelectionCommand implements ChartEditorCommand
 
 	public function undo(state:ChartEditorState):Void
 	{
-		state.currentSelection = previousSelection;
+		state.currentNoteSelection = previousNoteSelection;
+		state.currentEventSelection = previousEventSelection;
 
 		state.noteDisplayDirty = true;
 		state.notePreviewDirty = true;
@@ -301,29 +514,34 @@ class SetNoteSelectionCommand implements ChartEditorCommand
 
 	public function toString():String
 	{
-		return 'Select ${notes.length} Notes';
+		return 'Select ${notes.length} Items';
 	}
 }
 
-class SelectAllNotesCommand implements ChartEditorCommand
+class SelectAllItemsCommand implements ChartEditorCommand
 {
-	private var previousSelection:Array<SongNoteData>;
+	private var previousNoteSelection:Array<SongNoteData>;
+	private var previousEventSelection:Array<SongEventData>;
 
-	public function new(?previousSelection:Array<SongNoteData>)
+	public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
 	{
-		this.previousSelection = previousSelection == null ? [] : previousSelection;
+		this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
+		this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
 	}
 
 	public function execute(state:ChartEditorState):Void
 	{
-		state.currentSelection = state.currentSongChartNoteData;
+		state.currentNoteSelection = state.currentSongChartNoteData;
+		state.currentEventSelection = state.currentSongChartEventData;
+
 		state.noteDisplayDirty = true;
 		state.notePreviewDirty = true;
 	}
 
 	public function undo(state:ChartEditorState):Void
 	{
-		state.currentSelection = previousSelection;
+		state.currentNoteSelection = previousNoteSelection;
+		state.currentEventSelection = previousEventSelection;
 
 		state.noteDisplayDirty = true;
 		state.notePreviewDirty = true;
@@ -331,29 +549,33 @@ class SelectAllNotesCommand implements ChartEditorCommand
 
 	public function toString():String
 	{
-		return 'Select All Notes';
+		return 'Select All Items';
 	}
 }
 
-class InvertSelectedNotesCommand implements ChartEditorCommand
+class InvertSelectedItemsCommand implements ChartEditorCommand
 {
-	private var previousSelection:Array<SongNoteData>;
+	private var previousNoteSelection:Array<SongNoteData>;
+	private var previousEventSelection:Array<SongEventData>;
 
-	public function new(?previousSelection:Array<SongNoteData>)
+	public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
 	{
-		this.previousSelection = previousSelection == null ? [] : previousSelection;
+		this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
+		this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
 	}
 
 	public function execute(state:ChartEditorState):Void
 	{
-		state.currentSelection = SongDataUtils.subtractNotes(state.currentSongChartNoteData, previousSelection);
+		state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentSongChartNoteData, previousNoteSelection);
+		state.currentEventSelection = SongDataUtils.subtractEvents(state.currentSongChartEventData, previousEventSelection);
 		state.noteDisplayDirty = true;
 		state.notePreviewDirty = true;
 	}
 
 	public function undo(state:ChartEditorState):Void
 	{
-		state.currentSelection = previousSelection;
+		state.currentNoteSelection = previousNoteSelection;
+		state.currentEventSelection = previousEventSelection;
 
 		state.noteDisplayDirty = true;
 		state.notePreviewDirty = true;
@@ -361,22 +583,25 @@ class InvertSelectedNotesCommand implements ChartEditorCommand
 
 	public function toString():String
 	{
-		return 'Invert Selected Notes';
+		return 'Invert Selected Items';
 	}
 }
 
-class DeselectAllNotesCommand implements ChartEditorCommand
+class DeselectAllItemsCommand implements ChartEditorCommand
 {
-	private var previousSelection:Array<SongNoteData>;
+	private var previousNoteSelection:Array<SongNoteData>;
+	private var previousEventSelection:Array<SongEventData>;
 
-	public function new(?previousSelection:Array<SongNoteData>)
+	public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
 	{
-		this.previousSelection = previousSelection == null ? [] : previousSelection;
+		this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
+		this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
 	}
 
 	public function execute(state:ChartEditorState):Void
 	{
-		state.currentSelection = [];
+		state.currentNoteSelection = [];
+		state.currentEventSelection = [];
 
 		state.noteDisplayDirty = true;
 		state.notePreviewDirty = true;
@@ -384,7 +609,8 @@ class DeselectAllNotesCommand implements ChartEditorCommand
 
 	public function undo(state:ChartEditorState):Void
 	{
-		state.currentSelection = previousSelection;
+		state.currentNoteSelection = previousNoteSelection;
+		state.currentEventSelection = previousEventSelection;
 
 		state.noteDisplayDirty = true;
 		state.notePreviewDirty = true;
@@ -392,27 +618,35 @@ class DeselectAllNotesCommand implements ChartEditorCommand
 
 	public function toString():String
 	{
-		return 'Deselect All Notes';
+		return 'Deselect All Items';
 	}
 }
 
-class CutNotesCommand implements ChartEditorCommand
+class CutItemsCommand implements ChartEditorCommand
 {
 	private var notes:Array<SongNoteData>;
+	private var events:Array<SongEventData>;
 
-	public function new(notes:Array<SongNoteData>)
+	public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
 	{
 		this.notes = notes;
+		this.events = events;
 	}
 
 	public function execute(state:ChartEditorState):Void
 	{
 		// Copy the notes.
-		SongDataUtils.writeNotesToClipboard(SongDataUtils.buildClipboard(notes));
+		SongDataUtils.writeItemsToClipboard({
+			notes: SongDataUtils.buildNoteClipboard(notes),
+			events: SongDataUtils.buildEventClipboard(events)
+		});
 
 		// Delete the notes.
 		state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
-		state.currentSelection = [];
+		state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
+		state.currentNoteSelection = [];
+		state.currentEventSelection = [];
+
 		state.saveDataDirty = true;
 		state.noteDisplayDirty = true;
 		state.notePreviewDirty = true;
@@ -422,19 +656,27 @@ class CutNotesCommand implements ChartEditorCommand
 	public function undo(state:ChartEditorState):Void
 	{
 		state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes);
-		state.currentSelection = notes;
+		state.currentSongChartEventData = state.currentSongChartEventData.concat(events);
+
+		state.currentNoteSelection = notes;
+		state.currentEventSelection = events;
 
 		state.saveDataDirty = true;
 		state.noteDisplayDirty = true;
 		state.notePreviewDirty = true;
-
 		state.sortChartData();
 	}
 
 	public function toString():String
 	{
-		var len:Int = notes.length;
-		return 'Cut $len Notes to Clipboard';
+		var len:Int = notes.length + events.length;
+
+		if (notes.length == 0)
+			return 'Cut $len Events to Clipboard';
+		else if (events.length == 0)
+			return 'Cut $len Notes to Clipboard';
+		else
+			return 'Cut $len Items to Clipboard';
 	}
 }
 
@@ -457,7 +699,8 @@ class FlipNotesCommand implements ChartEditorCommand
 		flippedNotes = SongDataUtils.flipNotes(notes);
 		state.currentSongChartNoteData = state.currentSongChartNoteData.concat(flippedNotes);
 
-		state.currentSelection = flippedNotes;
+		state.currentNoteSelection = flippedNotes;
+		state.currentEventSelection = [];
 
 		state.saveDataDirty = true;
 		state.noteDisplayDirty = true;
@@ -470,7 +713,8 @@ class FlipNotesCommand implements ChartEditorCommand
 		state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, flippedNotes);
 		state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes);
 
-		state.currentSelection = notes;
+		state.currentNoteSelection = notes;
+		state.currentEventSelection = [];
 
 		state.saveDataDirty = true;
 		state.noteDisplayDirty = true;
@@ -486,11 +730,12 @@ class FlipNotesCommand implements ChartEditorCommand
 	}
 }
 
-class PasteNotesCommand implements ChartEditorCommand
+class PasteItemsCommand implements ChartEditorCommand
 {
 	private var targetTimestamp:Float;
 	// Notes we added with this command, for undo.
 	private var addedNotes:Array<SongNoteData>;
+	private var addedEvents:Array<SongEventData>;
 
 	public function new(targetTimestamp:Float)
 	{
@@ -499,12 +744,15 @@ class PasteNotesCommand implements ChartEditorCommand
 
 	public function execute(state:ChartEditorState):Void
 	{
-		var currentClipboard:Array<SongNoteData> = SongDataUtils.readNotesFromClipboard();
+		var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard();
 
-		addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard, Std.int(targetTimestamp));
+		addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard.notes, Std.int(targetTimestamp));
+		addedEvents = SongDataUtils.offsetSongEventData(currentClipboard.events, Std.int(targetTimestamp));
 
 		state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes);
-		state.currentSelection = addedNotes.copy();
+		state.currentSongChartEventData = state.currentSongChartEventData.concat(addedEvents);
+		state.currentNoteSelection = addedNotes.copy();
+		state.currentEventSelection = addedEvents.copy();
 
 		state.saveDataDirty = true;
 		state.noteDisplayDirty = true;
@@ -516,7 +764,9 @@ class PasteNotesCommand implements ChartEditorCommand
 	public function undo(state:ChartEditorState):Void
 	{
 		state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes);
-		state.currentSelection = [];
+		state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, addedEvents);
+		state.currentNoteSelection = [];
+		state.currentEventSelection = [];
 
 		state.saveDataDirty = true;
 		state.noteDisplayDirty = true;
@@ -527,52 +777,16 @@ class PasteNotesCommand implements ChartEditorCommand
 
 	public function toString():String
 	{
-		var currentClipboard:Array<SongNoteData> = SongDataUtils.readNotesFromClipboard();
-		return 'Paste ${currentClipboard.length} Notes from Clipboard';
-	}
-}
+		var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard();
 
-class AddEventsCommand implements ChartEditorCommand
-{
-	private var events:Array<SongEventData>;
-	private var appendToSelection:Bool;
+		var len:Int = currentClipboard.notes.length + currentClipboard.events.length;
 
-	public function new(events:Array<SongEventData>, ?appendToSelection:Bool = false)
-	{
-		this.events = events;
-		this.appendToSelection = appendToSelection;
-	}
-
-	public function execute(state:ChartEditorState):Void
-	{
-		state.currentSongChartEventData = state.currentSongChartEventData.concat(events);
-		// TODO: Allow selecting events.
-		// state.currentSelection = events;
-
-		state.saveDataDirty = true;
-		state.noteDisplayDirty = true;
-		state.notePreviewDirty = true;
-
-		state.sortChartData();
-	}
-
-	public function undo(state:ChartEditorState):Void
-	{
-		state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
-
-		state.currentSelection = [];
-
-		state.saveDataDirty = true;
-		state.noteDisplayDirty = true;
-		state.notePreviewDirty = true;
-
-		state.sortChartData();
-	}
-
-	public function toString():String
-	{
-		var len:Int = events.length;
-		return 'Add $len Events';
+		if (currentClipboard.notes.length == 0)
+			return 'Paste $len Events';
+		else if (currentClipboard.events.length == 0)
+			return 'Paste $len Notes';
+		else
+			return 'Paste $len Items';
 	}
 }
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
new file mode 100644
index 000000000..1c1580f7a
--- /dev/null
+++ b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
@@ -0,0 +1,101 @@
+package funkin.ui.debug.charting;
+
+import openfl.display.BitmapData;
+import openfl.utils.Assets;
+import flixel.FlxObject;
+import flixel.FlxBasic;
+import flixel.FlxSprite;
+import flixel.graphics.frames.FlxFramesCollection;
+import flixel.graphics.frames.FlxTileFrames;
+import flixel.math.FlxPoint;
+import funkin.play.song.SongData.SongEventData;
+
+/**
+ * A event sprite that can be used to display a song event in a chart.
+ * Designed to be used and reused efficiently. Has no gameplay functionality.
+ */
+class ChartEditorEventSprite extends FlxSprite
+{
+	public var parentState:ChartEditorState;
+
+	/**
+	 * The note data that this sprite represents.
+	 * You can set this to null to kill the sprite and flag it for recycling.
+	 */
+	public var eventData(default, set):SongEventData;
+
+	/**
+	 * The image used for all song events. Cached for performance.
+	 */
+	var eventGraphic:BitmapData;
+
+	public function new(parent:ChartEditorState)
+	{
+		super();
+
+		this.parentState = parent;
+
+		buildGraphic();
+	}
+
+	function buildGraphic():Void
+	{
+		if (eventGraphic == null)
+		{
+			eventGraphic = Assets.getBitmapData(Paths.image('ui/chart-editor/event'));
+		}
+
+		loadGraphic(eventGraphic);
+		setGraphicSize(ChartEditorState.GRID_SIZE);
+		this.updateHitbox();
+	}
+
+	function set_eventData(value:SongEventData):SongEventData
+	{
+		this.eventData = value;
+
+		if (this.eventData == null)
+		{
+			// Disown parent.
+			this.kill();
+			return this.eventData;
+		}
+
+		this.visible = true;
+
+		// Update the position to match the note data.
+		updateEventPosition();
+
+		return this.eventData;
+	}
+
+	public function updateEventPosition(?origin:FlxObject)
+	{
+		this.x = (ChartEditorState.STRUMLINE_SIZE * 2 + 1 - 1) * ChartEditorState.GRID_SIZE;
+		if (this.eventData.stepTime >= 0)
+			this.y = this.eventData.stepTime * ChartEditorState.GRID_SIZE;
+
+		if (origin != null)
+		{
+			this.x += origin.x;
+			this.y += origin.y;
+		}
+	}
+
+	/**
+	 * Return whether this note (or its parent) is currently visible.
+	 */
+	public function isEventVisible(viewAreaBottom:Float, viewAreaTop:Float):Bool
+	{
+		var outsideViewArea = (this.y + this.height < viewAreaTop || this.y > viewAreaBottom);
+
+		if (!outsideViewArea)
+		{
+			return true;
+		}
+
+		// TODO: Check if this note's parent or child is visible.
+
+		return false;
+	}
+}
diff --git a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
index cf1a2e018..bdf124766 100644
--- a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
@@ -169,7 +169,8 @@ class ChartEditorNoteSprite extends FlxSprite
 			if (this.noteData.stepTime >= 0)
 				this.y = this.noteData.stepTime * ChartEditorState.GRID_SIZE;
 
-			if (origin != null) {
+			if (origin != null)
+			{
 				this.x += origin.x;
 				this.y += origin.y;
 			}
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 9acaf59f0..b86aa5e88 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1,5 +1,6 @@
 package funkin.ui.debug.charting;
 
+import haxe.DynamicAccess;
 import haxe.io.Path;
 import flixel.addons.display.FlxSliceSprite;
 import flixel.addons.display.FlxTiledSprite;
@@ -72,3069 +73,3315 @@ using Lambda;
 @:allow(funkin.ui.debug.charting.ChartEditorToolboxHandler)
 class ChartEditorState extends HaxeUIState
 {
-	/**
-	 * CONSTANTS
-	 */
-	// ==============================
-	// XML Layouts
-	static final CHART_EDITOR_LAYOUT = Paths.ui('chart-editor/main-view');
-
-	static final CHART_EDITOR_NOTIFBAR_LAYOUT = Paths.ui('chart-editor/components/notifbar');
-	static final CHART_EDITOR_PLAYBARHEAD_LAYOUT = Paths.ui('chart-editor/components/playbar-head');
-
-	static final CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT = Paths.ui('chart-editor/toolbox/tools');
-	static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT = Paths.ui('chart-editor/toolbox/notedata');
-	static final CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT = Paths.ui('chart-editor/toolbox/eventdata');
-	static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT = Paths.ui('chart-editor/toolbox/metadata');
-	static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT = Paths.ui('chart-editor/toolbox/difficulty');
-	static final CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT = Paths.ui('chart-editor/toolbox/characters');
-	static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/player-preview');
-	static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/opponent-preview');
-
-	// The base grid size for the chart editor.
-	public static final GRID_SIZE:Int = 40;
-
-	// Number of notes in each strumline.
-	public static final STRUMLINE_SIZE = 4;
-
-	// The height of the menu bar in the layout.
-	static final MENU_BAR_HEIGHT = 32;
-
-	/**
-	 * Duration to wait before autosaving the chart.
-	 */
-	static final AUTOSAVE_TIMER_DELAY:Float = 60.0 * 5.0;
-
-	// The amount of padding between the menu bar and the chart grid when fully scrolled up.
-	static final GRID_TOP_PAD:Int = 8;
-
-	public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = 12;
-	public static final PLAYHEAD_HEIGHT:Int = Std.int(GRID_SIZE / 8);
-
-	public static final GRID_SELECTION_BORDER_WIDTH:Int = 6;
-
-	// Duration until notifications are automatically hidden.
-	static final NOTIFICATION_DISMISS_TIME:Float = 3.0;
-
-	// Start performing rapid undo after this many seconds.
-	static final RAPID_UNDO_DELAY:Float = 0.4;
-	// Perform a rapid undo every this many seconds.
-	static final RAPID_UNDO_INTERVAL:Float = 0.1;
-
-	// UI Element Colors
-	// Background color tint.
-	static final CURSOR_COLOR:FlxColor = 0xE0FFFFFF;
-	static final PREVIEW_BG_COLOR:FlxColor = 0xFF303030;
-	static final PLAYHEAD_SCROLL_AREA_COLOR:FlxColor = 0xFF682B2F;
-	static final SPECTROGRAM_COLOR:FlxColor = 0xFFFF0000;
-	static final PLAYHEAD_COLOR:FlxColor = 0xC0BD0231;
-
-	/**
-	 * How many pixels far the user needs to move the mouse before the cursor is considered to be dragged rather than clicked.
-	 */
-	static final DRAG_THRESHOLD:Float = 16.0;
-
-	/**
-	 * Types of notes you can snap to.
-	 */
-	static final SNAP_QUANTS:Array<Int> = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96, 192];
-
-	/**
-	 * INSTANCE DATA
-	 */
-	// ==============================
-	public var currentZoomLevel:Float = 1.0;
-
-	var noteSnapQuantIndex:Int = 3;
-
-	public var noteSnapQuant(get, never):Int;
-
-	function get_noteSnapQuant():Int
-	{
-		return SNAP_QUANTS[noteSnapQuantIndex];
-	}
-
-	/**
-	 * scrollPosition is the current position in the song, in pixels.
-	 * One pixel is 1/40 of 1 step, and 1/160 of 1 beat.
-	 */
-	var scrollPositionInPixels(default, set):Float = -1.0;
-
-	/**
-	 * scrollPosition, converted to steps.
-	 * TODO: Handle BPM changes.
-	 */
-	var scrollPositionInSteps(get, null):Float;
-
-	function get_scrollPositionInSteps():Float
-	{
-		return scrollPositionInPixels / GRID_SIZE;
-	}
-
-	/**
-	 * scrollPosition, converted to milliseconds.
-	 * TODO: Handle BPM changes.
-	 */
-	var scrollPositionInMs(get, set):Float;
-
-	function get_scrollPositionInMs():Float
-	{
-		return scrollPositionInSteps * Conductor.stepCrochet;
-	}
-
-	function set_scrollPositionInMs(value:Float):Float
-	{
-		scrollPositionInPixels = value / Conductor.stepCrochet;
-		return value;
-	}
-
-	/**
-	 * The position of the playhead, in pixels, relative to the scrollPosition.
-	 * 0 means playhead is at the top of the grid.
-	 * 40 means the playhead is 1 grid length below the base position.
-	 * -40 means the playhead is 1 grid length above the base position.
-	 */
-	var playheadPositionInPixels(default, set):Float;
-
-	var playheadPositionInSteps(get, null):Float;
-
-	/**
-	 * playheadPosition, converted to steps.
-	 */
-	function get_playheadPositionInSteps():Float
-	{
-		return playheadPositionInPixels / GRID_SIZE;
-	}
-
-	/**
-	 * playheadPosition, converted to milliseconds.
-	 */
-	var playheadPositionInMs(get, null):Float;
-
-	function get_playheadPositionInMs():Float
-	{
-		return playheadPositionInSteps * Conductor.stepCrochet;
-	}
-
-	/**
-	 * This is the song's length in PIXELS, same format as scrollPosition.
-	 */
-	var songLengthInPixels(get, default):Int;
-
-	function get_songLengthInPixels():Int
-	{
-		if (songLengthInPixels <= 0)
-			return 1000;
-
-		return songLengthInPixels;
-	}
-
-	/**
-	 * songLength, converted to steps.
-	 * TODO: Handle BPM changes.
-	 */
-	var songLengthInSteps(get, set):Float;
-
-	function get_songLengthInSteps():Float
-	{
-		return songLengthInPixels / GRID_SIZE;
-	}
-
-	function set_songLengthInSteps(value:Float):Float
-	{
-		songLengthInPixels = Std.int(value * GRID_SIZE);
-		return value;
-	}
-
-	/**
-	 * songLength, converted to milliseconds.
-	 * TODO: Handle BPM changes.
-	 */
-	var songLengthInMs(get, set):Float;
-
-	function get_songLengthInMs():Float
-	{
-		return songLengthInSteps * Conductor.stepCrochet;
-	}
-
-	function set_songLengthInMs(value:Float):Float
-	{
-		songLengthInSteps = Conductor.getTimeInSteps(audioInstTrack.length);
-		return value;
-	}
-
-	var currentTheme(default, set):ChartEditorTheme = null;
-
-	function set_currentTheme(value:ChartEditorTheme):ChartEditorTheme
-	{
-		if (value == null || value == currentTheme)
-			return currentTheme;
-
-		currentTheme = value;
-		ChartEditorThemeHandler.updateTheme(this);
-		return value;
-	}
-
-	/**
-	 * Whether a skip button has been pressed on the playbar, and which one.
-	 * This will be used to update the scrollPosition (in the same function that handles the scroll wheel), then cleared.
-	 */
-	var playbarButtonPressed:String = null;
-
-	/**
-	 * Whether the head of the playbar is currently being dragged with the mouse by the user.
-	 */
-	var playbarHeadDragging:Bool = false;
-
-	/**
-	 * Whether music was playing before we started dragging the playbar head.
-	 * If so, then when we stop dragging the playbar head, we should resume song playback.
-	 */
-	var playbarHeadDraggingWasPlaying:Bool = false;
-
-	/**
-	 * The note kind to use for notes being placed in the chart. Defaults to `''`.
-	 */
-	var selectedNoteKind:String = '';
-
-	/**
-	 * Whether to play a metronome sound while the playhead is moving.
-	 */
-	var shouldPlayMetronome:Bool = true;
-
-	/**
-	 * Use the tool window to affect how the user interacts with the program.
-	 */
-	var currentToolMode:ChartEditorToolMode = ChartEditorToolMode.Select;
-
-	/**
-	 * The character sprite in the Player Preview window.
-	 */
-	var currentPlayerCharacterPlayer:CharacterPlayer = null;
-
-	/**
-	 * The character sprite in the Opponent Preview window.
-	 */
-	var currentOpponentCharacterPlayer:CharacterPlayer = null;
-
-	/**
-	 * Whether the current view is in downscroll mode.
-	 */
-	var isViewDownscroll(default, set):Bool = false;
-
-	function set_isViewDownscroll(value:Bool):Bool
-	{
-		isViewDownscroll = value;
-
-		// Make sure view is updated when we change view modes.
-		noteDisplayDirty = true;
-		notePreviewDirty = true;
-		this.scrollPositionInPixels = this.scrollPositionInPixels;
-
-		return isViewDownscroll;
-	}
-
-	/**
-	 * Whether hitsounds are enabled for at least one character.
-	 */
-	var hitsoundsEnabled(get, null):Bool;
-
-	function get_hitsoundsEnabled():Bool
-	{
-		return hitsoundsEnabledPlayer || hitsoundsEnabledOpponent;
-	}
-
-	/**
-	 * Whether hitsounds are enabled for the player.
-	 */
-	var hitsoundsEnabledPlayer:Bool = true;
-
-	/**
-	 * Whether hitsounds are enabled for the opponent.
-	 */
-	var hitsoundsEnabledOpponent:Bool = true;
-
-	/**
-	 * Whether the user's mouse cursor is hovering over a SOLID component of the HaxeUI.
-	 * If so, ignore mouse events underneath.
-	 */
-	var isCursorOverHaxeUI(get, null):Bool;
-
-	function get_isCursorOverHaxeUI():Bool
-	{
-		return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
-	}
-
-	var isCursorOverHaxeUIButton(get, null):Bool;
-
-	function get_isCursorOverHaxeUIButton():Bool
-	{
-		return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Button)
-			|| Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Link);
-	}
-
-	/**
-	 * Set by ChartEditorDialogHandler, used to prevent background interaction while the dialog is open.
-	 */
-	public var isHaxeUIDialogOpen:Bool = false;
-
-	/**
-	 * The variation ID for the difficulty which is currently being edited.
-	 */
-	var selectedVariation(default, set):String = Constants.DEFAULT_VARIATION;
-
-	function set_selectedVariation(value:String):String
-	{
-		selectedVariation = value;
-
-		// Make sure view is updated when the variation changes.
-		noteDisplayDirty = true;
-		notePreviewDirty = true;
-
-		return selectedVariation;
-	}
-
-	/**
-	 * The difficulty ID for the difficulty which is currently being edited.
-	 */
-	var selectedDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY;
-
-	function set_selectedDifficulty(value:String):String
-	{
-		selectedDifficulty = value;
-
-		// Make sure view is updated when the difficulty changes.
-		noteDisplayDirty = true;
-		notePreviewDirty = true;
-
-		return selectedDifficulty;
-	}
-
-	/**
-	 * Whether the user is currently in Pattern Mode.
-	 * This overrides the chart editor's normal behavior.
-	 */
-	var isInPatternMode(default, set):Bool = false;
-
-	function set_isInPatternMode(value:Bool):Bool
-	{
-		isInPatternMode = value;
-
-		// Make sure view is updated when we change modes.
-		noteDisplayDirty = true;
-		notePreviewDirty = true;
-		this.scrollPositionInPixels = 0;
-
-		return isInPatternMode;
-	}
-
-	var currentPattern:String = '';
-
-	/**
-	 * Whether the note display render group has been modified and needs to be updated.
-	 * This happens when we scroll or add/remove notes, and need to update what notes are displayed and where.
-	 */
-	var noteDisplayDirty:Bool = true;
-
-	/**
-	 * Whether the note preview graphic needs to be FULLY rebuilt.
-	 * The Bitmap can be modified by individual commands without using this.
-	 */
-	var notePreviewDirty:Bool = true;
-
-	/**
-	 * Whether the chart has been modified since it was last saved.
-	 * Used to determine whether to auto-save, etc.
-	 */
-	var saveDataDirty(default, set):Bool = false;
-
-	function set_saveDataDirty(value:Bool):Bool
-	{
-		if (value == saveDataDirty)
-			return value;
-
-		if (value)
-		{
-			// Start the auto-save timer.
-			autoSaveTimer = new FlxTimer().start(AUTOSAVE_TIMER_DELAY, (_) -> autoSave());
-		}
-		else
-		{
-			// Stop the auto-save timer.
-			autoSaveTimer.cancel();
-			autoSaveTimer.destroy();
-			autoSaveTimer = null;
-		}
-
-		return saveDataDirty = value;
-	}
-
-	/**
-	 * A timer used to auto-save the chart after a period of inactivity.
-	 */
-	var autoSaveTimer:FlxTimer;
-
-	/**
-	 * Whether the difficulty tree view in the toolbox has been modified and needs to be updated.
-	 * This happens when we add/remove difficulties.
-	 */
-	var difficultySelectDirty:Bool = true;
-
-	/**
-	 * Whether the character select view in the toolbox has been modified and needs to be updated.
-	 * This happens when we add/remove characters.
-	 */
-	var characterSelectDirty:Bool = true;
-
-	var isInPlaytestMode:Bool = false;
-
-	/**
-	 * The list of command previously performed. Used for undoing previous actions.
-	 */
-	var undoHistory:Array<ChartEditorCommand> = [];
-
-	/**
-	 * The list of commands that have been undone. Used for redoing previous actions.
-	 */
-	var redoHistory:Array<ChartEditorCommand> = [];
-
-	var undoHeldTime:Float = 0.0;
-
-	var redoHeldTime:Float = 0.0;
-
-	/**
-	 * Whether the undo/redo histories have changed since the last time the UI was updated.
-	 */
-	var commandHistoryDirty:Bool = true;
-
-	/**
-	 * The notes which are currently in the selection.
-	 */
-	var currentSelection:Array<SongNoteData> = [];
-
-	/**
-	 * The position where the user clicked to start a selection.
-	 * The selection box extends from this point to the current mouse position.
-	 */
-	var selectionBoxStartPos:FlxPoint = null;
-
-	/**
-	 * Whether the user's last mouse click was on the playhead scroll area.
-	 */
-	var gridPlayheadScrollAreaPressed:Bool = false;
-
-	/**
-	 * The SongNoteData which is currently being placed.
-	 * As the user drags, we will update this note's sustain length.
-	 */
-	var currentPlaceNoteData:SongNoteData = null;
-
-	/**
-	 * The Dialog components representing the currently available tool windows.
-	 * Dialogs are retained here even when collapsed or hidden.
-	 */
-	var activeToolboxes:Map<String, Dialog> = new Map<String, Dialog>();
-
-	/**
-	 * AUDIO AND SOUND DATA
-	 */
-	// ==============================
-
-	/**
-	 * The audio track for the instrumental.
-	 */
-	var audioInstTrack:FlxSound;
-
-	/**
-	 * The audio track for the vocals.
-	 */
-	var audioVocalTrackGroup:VocalGroup;
-
-	/**
-	 * A map of the audio tracks for each character's vocals.
-	 * - Keys are the character IDs.
-	 * - Values are the FlxSound objects to play that character's vocals.
-	 * 
-	 * When switching characters, the elements of the VocalGroup will be swapped to match the new character.
-	 */
-	var audioVocalTracks:Map<String, FlxSound> = new Map<String, FlxSound>();
-
-	/**
-	 * CHART DATA
-	 */
-	// ==============================
-
-	/**
-	 * The song metadata.
-	 * - Keys are the variation IDs. At least one (`default`) must exist.
-	 * - Values are the relevant metadata, ready to be serialized to JSON.
-	 */
-	var songMetadata:Map<String, SongMetadata>;
-
-	var availableVariations(get, null):Array<String>;
-
-	function get_availableVariations():Array<String>
-	{
-		return [for (x in songMetadata.keys()) x];
-	}
-
-	/**
-	 * The song chart data.
-	 * - Keys are the variation IDs. At least one (`default`) must exist.
-	 * - Values are the relevant chart data, ready to be serialized to JSON.
-	 */
-	var songChartData:Map<String, SongChartData>;
-
-	/**
-	 * Convenience property to get the chart data for the current variation.
-	 */
-	var currentSongMetadata(get, set):SongMetadata;
-
-	function get_currentSongMetadata():SongMetadata
-	{
-		var result = songMetadata.get(selectedVariation);
-		if (result == null)
-		{
-			result = new SongMetadata('Dad Battle', 'Kawai Sprite', selectedVariation);
-			songMetadata.set(selectedVariation, result);
-		}
-		return result;
-	}
-
-	function set_currentSongMetadata(value:SongMetadata):SongMetadata
-	{
-		songMetadata.set(selectedVariation, value);
-		return value;
-	}
-
-	/**
-	 * Convenience property to get the chart data for the current variation.
-	 */
-	var currentSongChartData(get, set):SongChartData;
-
-	function get_currentSongChartData():SongChartData
-	{
-		var result = songChartData.get(selectedVariation);
-		if (result == null)
-		{
-			result = new SongChartData(1.0, [], []);
-			songChartData.set(selectedVariation, result);
-		}
-		return result;
-	}
-
-	function set_currentSongChartData(value:SongChartData):SongChartData
-	{
-		songChartData.set(selectedVariation, value);
-		return value;
-	}
-
-	/**
-	 * Convenience property to get (and set) the scroll speed for the current difficulty.
-	 */
-	var currentSongChartScrollSpeed(get, set):Float;
-
-	function get_currentSongChartScrollSpeed():Float
-	{
-		var result = currentSongChartData.scrollSpeed.get(selectedDifficulty);
-		if (result == null)
-		{
-			// Initialize to the default value if not set.
-			currentSongChartData.scrollSpeed.set(selectedDifficulty, 1.0);
-			return 1.0;
-		}
-		return result;
-	}
-
-	function set_currentSongChartScrollSpeed(value:Float):Float
-	{
-		currentSongChartData.scrollSpeed.set(selectedDifficulty, value);
-		return value;
-	}
-
-	/**
-	 * Convenience property to get the note data for the current difficulty.
-	 */
-	var currentSongChartNoteData(get, set):Array<SongNoteData>;
-
-	function get_currentSongChartNoteData():Array<SongNoteData>
-	{
-		var result = currentSongChartData.notes.get(selectedDifficulty);
-		if (result == null)
-		{
-			// Initialize to the default value if not set.
-			result = [];
-			currentSongChartData.notes.set(selectedDifficulty, result);
-			return result;
-		}
-		return result;
-	}
-
-	function set_currentSongChartNoteData(value:Array<SongNoteData>):Array<SongNoteData>
-	{
-		currentSongChartData.notes.set(selectedDifficulty, value);
-		return value;
-	}
-
-	/**
-	 * Convenience property to get the event data for the current difficulty.
-	 */
-	var currentSongChartEventData(get, set):Array<SongEventData>;
-
-	function get_currentSongChartEventData():Array<SongEventData>
-	{
-		if (currentSongChartData.events == null)
-		{
-			// Initialize to the default value if not set.
-			currentSongChartData.events = [];
-		}
-		return currentSongChartData.events;
-	}
-
-	function set_currentSongChartEventData(value:Array<SongEventData>):Array<SongEventData>
-	{
-		currentSongChartData.events = value;
-		return value;
-	}
-
-	public var currentSongNoteSkin(get, set):String;
-
-	function get_currentSongNoteSkin():String
-	{
-		if (currentSongMetadata.playData.noteSkin == null)
-		{
-			// Initialize to the default value if not set.
-			currentSongMetadata.playData.noteSkin = 'Normal';
-		}
-		return currentSongMetadata.playData.noteSkin;
-	}
-
-	function set_currentSongNoteSkin(value:String):String
-	{
-		return currentSongMetadata.playData.noteSkin = value;
-	}
-
-	var currentSongStage(get, set):String;
-
-	function get_currentSongStage():String
-	{
-		if (currentSongMetadata.playData.stage == null)
-		{
-			// Initialize to the default value if not set.
-			currentSongMetadata.playData.stage = 'mainStage';
-		}
-		return currentSongMetadata.playData.stage;
-	}
-
-	function set_currentSongStage(value:String):String
-	{
-		return currentSongMetadata.playData.stage = value;
-	}
-
-	var currentSongName(get, set):String;
-
-	function get_currentSongName():String
-	{
-		if (currentSongMetadata.songName == null)
-		{
-			// Initialize to the default value if not set.
-			currentSongMetadata.songName = 'New Song';
-		}
-		return currentSongMetadata.songName;
-	}
-
-	function set_currentSongName(value:String):String
-	{
-		return currentSongMetadata.songName = value;
-	}
-
-	var currentSongId(get, null):String;
-
-	function get_currentSongId():String
-	{
-		return currentSongName.toLowerKebabCase();
-	}
-
-	var currentSongArtist(get, set):String;
-
-	function get_currentSongArtist():String
-	{
-		if (currentSongMetadata.artist == null)
-		{
-			// Initialize to the default value if not set.
-			currentSongMetadata.artist = 'Unknown';
-		}
-		return currentSongMetadata.artist;
-	}
-
-	function set_currentSongArtist(value:String):String
-	{
-		return currentSongMetadata.artist = value;
-	}
-
-	/**
-	 * RENDER OBJECTS
-	 */
-	// ==============================
-
-	/**
-	 * The IMAGE used for the grid. Updated by ChartEditorThemeHandler.
-	 */
-	var gridBitmap:BitmapData;
-
-	/**
-	 * The IMAGE used for the selection squares. Updated by ChartEditorThemeHandler.
-	 * Used two ways:
-	 * 1. A sprite is given this bitmap and placed over selected notes.
-	 * 2. The image is split and used for a 9-slice sprite for the selection box.
-	 */
-	var selectionSquareBitmap:BitmapData = null;
-
-	/**
-	 * The tiled sprite used to display the grid.
-	 * The height is the length of the song, and scrolling is done by simply the sprite.
-	 */
-	var gridTiledSprite:FlxSprite;
-
-	/**
-	 * The playhead representing the current position in the song.
-	 * Can move around on the grid independently of the view.
-	 */
-	var gridPlayhead:FlxSpriteGroup;
-
-	var gridPlayheadScrollArea:FlxSprite;
-
-	/**
-	 * A sprite used to indicate the note that will be placed on click.
-	 */
-	var gridGhostNote:ChartEditorNoteSprite;
-
-	/**
-	 * The waveform which (optionally) displays over the grid, underneath the notes and playhead.
-	 */
-	var gridSpectrogram:PolygonSpectogram;
-
-	/**
-	 * The rectangle used for the note preview area.
-	 * Should span the full height of the song. We scribble on this to draw the preview.
-	 */
-	var notePreviewBitmap:BitmapData;
-
-	/**
-	 * The sprite used to display the note preview area.
-	 * We move this up and down to scroll the preview.
-	 */
-	var notePreviewSprite:FlxSprite;
-
-	/**
-	 * The rectangular sprite used for rendering the selection box.
-	 * Uses a 9-slice to stretch the selection box to the correct size without warping.
-	 */
-	var selectionBoxSprite:FlxSliceSprite;
-
-	/**
-	 * The opponent's health icon.
-	 */
-	var healthIconDad:HealthIcon;
-
-	/**
-	 * The player's health icon.
-	 */
-	var healthIconBF:HealthIcon;
-
-	/**
-	 * The purple background sprite.
-	 */
-	var menuBG:FlxSprite;
-
-	/**
-	 * The sprite group containing the note graphics.
-	 * Only displays a subset of the data from `currentSongChartNoteData`,
-	 * and kills notes that are off-screen to be recycled later.
-	 */
-	var renderedNotes:FlxTypedSpriteGroup<ChartEditorNoteSprite>;
-
-	var renderedNoteSelectionSquares:FlxTypedSpriteGroup<FlxSprite>;
-
-	var notifBar:SideBar;
-	var playbarHead:Slider;
-
-	public function new()
-	{
-		// Load the HaxeUI XML file.
-		super(CHART_EDITOR_LAYOUT);
-	}
-
-	override function create()
-	{
-		// Get rid of any music from the previous state.
-		FlxG.sound.music.stop();
-
-		buildDefaultSongData();
-
-		buildBackground();
-
-		currentTheme = ChartEditorTheme.Light;
-
-		buildGrid();
-		buildSelectionBox();
-
-		// Add the HaxeUI components after the grid so they're on top.
-		super.create();
-		buildAdditionalUI();
-
-		// Setup the onClick listeners for the UI after it's been created.
-		setupUIListeners();
-
-		setupAutoSave();
-
-		// TODO: We should be loading the music later when the user requests it.
-		// loadDefaultMusic();
-
-		// TODO: Change to false.
-		var canCloseInitialDialog = true;
-		ChartEditorDialogHandler.openWelcomeDialog(this, canCloseInitialDialog);
-	}
-
-	function buildDefaultSongData()
-	{
-		selectedVariation = Constants.DEFAULT_VARIATION;
-		selectedDifficulty = Constants.DEFAULT_DIFFICULTY;
-
-		// Initialize the song metadata.
-		songMetadata = new Map<String, SongMetadata>();
-
-		// Initialize the song chart data.
-		songChartData = new Map<String, SongChartData>();
-
-		audioVocalTrackGroup = new VocalGroup();
-	}
-
-	/**
-	 * Builds and displays the background sprite.
-	 */
-	function buildBackground()
-	{
-		menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat'));
-		add(menuBG);
-
-		menuBG.setGraphicSize(Std.int(menuBG.width * 1.1));
-		menuBG.updateHitbox();
-		menuBG.screenCenter();
-		menuBG.scrollFactor.set(0, 0);
-	}
-
-	/**
-	 * Builds and displays the chart editor grid, including the playhead and cursor.
-	 */
-	function buildGrid()
-	{
-		gridTiledSprite = new FlxTiledSprite(gridBitmap, gridBitmap.width, 1000, false, true);
-		gridTiledSprite.x = FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; // Center the grid.
-		gridTiledSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; // Push down to account for the menu bar.
-		add(gridTiledSprite);
-
-		gridGhostNote = new ChartEditorNoteSprite(this);
-		gridGhostNote.alpha = 0.6;
-		gridGhostNote.noteData = new SongNoteData(-1, -1, 0, "");
-		gridGhostNote.visible = false;
-		add(gridGhostNote);
-
-		buildNoteGroup();
-
-		gridPlayheadScrollArea = new FlxSprite(gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH,
-			MENU_BAR_HEIGHT).makeGraphic(PLAYHEAD_SCROLL_AREA_WIDTH, FlxG.height - MENU_BAR_HEIGHT, PLAYHEAD_SCROLL_AREA_COLOR);
-		add(gridPlayheadScrollArea);
-
-		// The playhead that show the current position in the song.
-		gridPlayhead = new FlxSpriteGroup();
-		add(gridPlayhead);
-
-		var playheadWidth = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH * 2);
-		var playheadBaseYPos = MENU_BAR_HEIGHT + GRID_TOP_PAD;
-		gridPlayhead.setPosition(gridTiledSprite.x, playheadBaseYPos);
-		var playheadSprite = new FlxSprite().makeGraphic(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR);
-		playheadSprite.x = -PLAYHEAD_SCROLL_AREA_WIDTH;
-		playheadSprite.y = 0;
-		gridPlayhead.add(playheadSprite);
-
-		var playheadBlock = ChartEditorThemeHandler.buildPlayheadBlock();
-		playheadBlock.x = -PLAYHEAD_SCROLL_AREA_WIDTH;
-		playheadBlock.y = -PLAYHEAD_HEIGHT / 2;
-		gridPlayhead.add(playheadBlock);
-
-		// Character icons.
-		healthIconDad = new HealthIcon('dad');
-		healthIconDad.autoUpdate = false;
-		healthIconDad.size.set(0.5, 0.5);
-		healthIconDad.x = gridTiledSprite.x - 15 - (HealthIcon.HEALTH_ICON_SIZE * 0.5);
-		healthIconDad.y = gridTiledSprite.y + 5;
-		add(healthIconDad);
-
-		healthIconBF = new HealthIcon('bf');
-		healthIconBF.autoUpdate = false;
-		healthIconBF.size.set(0.5, 0.5);
-		healthIconBF.x = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15;
-		healthIconBF.y = gridTiledSprite.y + 5;
-		healthIconBF.flipX = true;
-		add(healthIconBF);
-	}
-
-	function buildSelectionBox()
-	{
-		selectionBoxSprite.scrollFactor.set(0, 0);
-		add(selectionBoxSprite);
-
-		setSelectionBoxBounds();
-	}
-
-	function setSelectionBoxBounds(?bounds:FlxRect = null)
-	{
-		if (bounds == null)
-		{
-			selectionBoxSprite.visible = false;
-			selectionBoxSprite.x = -9999;
-			selectionBoxSprite.y = -9999;
-		}
-		else
-		{
-			selectionBoxSprite.visible = true;
-			selectionBoxSprite.x = bounds.x;
-			selectionBoxSprite.y = bounds.y;
-			selectionBoxSprite.width = bounds.width;
-			selectionBoxSprite.height = bounds.height;
-		}
-	}
-
-	function buildSpectrogram(target:FlxSound)
-	{
-		gridSpectrogram = new PolygonSpectogram(target, SPECTROGRAM_COLOR, FlxG.height / 2, Math.floor(FlxG.height / 2));
-		// Halfway through the grid.
-		// gridSpectrogram.x = gridTiledSprite.x + STRUMLINE_SIZE * GRID_SIZE;
-		// gridSpectrogram.y = gridTiledSprite.y;
-		gridSpectrogram.x = 200;
-		gridSpectrogram.y = 200;
-		gridSpectrogram.visType = STATIC; // We move the spectrogram manually.
-		gridSpectrogram.waveAmplitude = 50;
-		gridSpectrogram.scrollFactor.set(0, 0);
-		add(gridSpectrogram);
-	}
-
-	/**
-	 * Builds the group that will hold all the notes.
-	 */
-	function buildNoteGroup()
-	{
-		renderedNotes = new FlxTypedSpriteGroup<ChartEditorNoteSprite>();
-		renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y);
-		add(renderedNotes);
-
-		renderedNoteSelectionSquares = new FlxTypedSpriteGroup<FlxSprite>();
-		renderedNoteSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y);
-		add(renderedNoteSelectionSquares);
-
-		/*
-			var sustainSprite:SustainTrail = new SustainTrail(0, 600, Paths.image('NOTE_hold_assets'), 0.9, false);
-			sustainSprite.scrollFactor.set(0, 0);
-			sustainSprite.x = gridTiledSprite.x;
-			sustainSprite.y = gridTiledSprite.y + 32;
-			sustainSprite.zoom *= 0.258; // 0.77;
-			add(sustainSprite);
-		 */
-	}
-
-	var playbarHeadLayout:Component;
-
-	function buildAdditionalUI():Void
-	{
-		notifBar = cast buildComponent(CHART_EDITOR_NOTIFBAR_LAYOUT);
-
-		add(notifBar);
-
-		playbarHeadLayout = buildComponent(CHART_EDITOR_PLAYBARHEAD_LAYOUT);
-
-		playbarHeadLayout.width = FlxG.width - 8;
-		playbarHeadLayout.height = 10;
-		playbarHeadLayout.x = 4;
-		playbarHeadLayout.y = FlxG.height - 48 - 8;
-
-		playbarHead = playbarHeadLayout.findComponent('playbarHead', Slider);
-		playbarHead.allowFocus = false;
-		playbarHead.width = FlxG.width;
-		playbarHead.height = 10;
-		playbarHead.styleString = "padding-left: 0px; padding-right: 0px; border-left: 0px; border-right: 0px;";
-
-		playbarHead.onDragStart = function(_:DragEvent)
-		{
-			playbarHeadDragging = true;
-
-			// If we were dragging the playhead while the song was playing, resume playing.
-			if (audioInstTrack != null && audioInstTrack.playing)
-			{
-				playbarHeadDraggingWasPlaying = true;
-				stopAudioPlayback();
-			}
-			else
-			{
-				playbarHeadDraggingWasPlaying = false;
-			}
-		}
-
-		playbarHead.onDragEnd = function(_:DragEvent)
-		{
-			playbarHeadDragging = false;
-
-			// Set the song position to where the playhead was moved to.
-			scrollPositionInPixels = songLengthInPixels * (playbarHead.value / 100);
-			// Update the conductor and audio tracks to match.
-			moveSongToScrollPosition();
-
-			// If we were dragging the playhead while the song was playing, resume playing.
-			if (playbarHeadDraggingWasPlaying)
-			{
-				playbarHeadDraggingWasPlaying = false;
-				startAudioPlayback();
-			}
-		}
-
-		add(playbarHeadLayout);
-	}
-
-	/**
-	 * Sets up the onClick listeners for the UI.
-	 */
-	function setupUIListeners():Void
-	{
-		// Add functionality to the playbar.
-
-		addUIClickListener('playbarPlay', (event:MouseEvent) -> toggleAudioPlayback());
-		addUIClickListener('playbarStart', (event:MouseEvent) -> playbarButtonPressed = 'playbarStart');
-		addUIClickListener('playbarBack', (event:MouseEvent) -> playbarButtonPressed = 'playbarBack');
-		addUIClickListener('playbarForward', (event:MouseEvent) -> playbarButtonPressed = 'playbarForward');
-		addUIClickListener('playbarEnd', (event:MouseEvent) -> playbarButtonPressed = 'playbarEnd');
-
-		// Add functionality to the menu items.
-
-		addUIClickListener('menubarItemNewChart', (event:MouseEvent) -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
-		addUIClickListener('menubarItemSaveChartAs', (event:MouseEvent) -> exportAllSongData());
-		addUIClickListener('menubarItemLoadInst', (event:MouseEvent) -> ChartEditorDialogHandler.openUploadInstDialog(this, true));
-
-		addUIClickListener('menubarItemUndo', (event:MouseEvent) -> undoLastCommand());
-
-		addUIClickListener('menubarItemRedo', (event:MouseEvent) -> redoLastCommand());
-
-		addUIClickListener('menubarItemCopy', (event:MouseEvent) ->
-		{
-			SongDataUtils.writeNotesToClipboard(SongDataUtils.buildClipboard(currentSelection));
-		});
-
-		addUIClickListener('menubarItemCut', (event:MouseEvent) ->
-		{
-			performCommand(new CutNotesCommand(currentSelection));
-		});
-
-		addUIClickListener('menubarItemPaste', (event:MouseEvent) ->
-		{
-			performCommand(new PasteNotesCommand(scrollPositionInMs + playheadPositionInMs));
-		});
-
-		addUIClickListener('menubarItemDelete', (event:MouseEvent) ->
-		{
-			performCommand(new RemoveNotesCommand(currentSelection));
-		});
-
-		addUIClickListener('menubarItemSelectAll', (event:MouseEvent) ->
-		{
-			performCommand(new SelectAllNotesCommand(currentSelection));
-		});
-
-		addUIClickListener('menubarItemSelectInverse', (event:MouseEvent) ->
-		{
-			performCommand(new InvertSelectedNotesCommand(currentSelection));
-		});
-
-		addUIClickListener('menubarItemSelectNone', (event:MouseEvent) ->
-		{
-			performCommand(new DeselectAllNotesCommand(currentSelection));
-		});
-
-		addUIClickListener('menubarItemSelectRegion', (event:MouseEvent) -> {
-			// TODO: Implement this.
-		});
-
-		addUIClickListener('menubarItemSelectBeforeCursor', (event:MouseEvent) -> {
-			// TODO: Implement this.
-		});
-
-		addUIClickListener('menubarItemSelectAfterCursor', (event:MouseEvent) -> {
-			// TODO: Implement this.
-		});
-
-		addUIClickListener('menubarItemAbout', (event:MouseEvent) -> ChartEditorDialogHandler.openAboutDialog(this));
-
-		addUIClickListener('menubarItemUserGuide', (event:MouseEvent) -> ChartEditorDialogHandler.openUserGuideDialog(this));
-
-		addUIChangeListener('menubarItemDownscroll', (event:UIEvent) ->
-		{
-			isViewDownscroll = event.value;
-		});
-		setUICheckboxSelected('menubarItemDownscroll', isViewDownscroll);
-
-		addUIChangeListener('menuBarItemThemeLight', (event:UIEvent) ->
-		{
-			if (event.target.value)
-				currentTheme = ChartEditorTheme.Light;
-		});
-		setUICheckboxSelected('menuBarItemThemeLight', currentTheme == ChartEditorTheme.Light);
-
-		addUIChangeListener('menuBarItemThemeDark', (event:UIEvent) ->
-		{
-			if (event.target.value)
-				currentTheme = ChartEditorTheme.Dark;
-		});
-		setUICheckboxSelected('menuBarItemThemeDark', currentTheme == ChartEditorTheme.Dark);
-
-		addUIChangeListener('menubarItemMetronomeEnabled', (event:UIEvent) ->
-		{
-			shouldPlayMetronome = event.value;
-		});
-		setUICheckboxSelected('menubarItemMetronomeEnabled', shouldPlayMetronome);
-
-		addUIChangeListener('menubarItemPlayerHitsounds', (event:UIEvent) ->
-		{
-			hitsoundsEnabledPlayer = event.value;
-		});
-		setUICheckboxSelected('menubarItemPlayerHitsounds', hitsoundsEnabledPlayer);
-
-		addUIChangeListener('menubarItemOpponentHitsounds', (event:UIEvent) ->
-		{
-			hitsoundsEnabledOpponent = event.value;
-		});
-		setUICheckboxSelected('menubarItemOpponentHitsounds', hitsoundsEnabledOpponent);
-
-		var instVolumeLabel:Label = findComponent('menubarLabelVolumeInstrumental', Label);
-		addUIChangeListener('menubarItemVolumeInstrumental', (event:UIEvent) ->
-		{
-			var volume:Float = event.value / 100.0;
-			if (audioInstTrack != null)
-				audioInstTrack.volume = volume;
-			instVolumeLabel.text = 'Instrumental - ${Std.int(event.value)}%';
-		});
-
-		var vocalsVolumeLabel:Label = findComponent('menubarLabelVolumeVocals', Label);
-		addUIChangeListener('menubarItemVolumeVocals', (event:UIEvent) ->
-		{
-			var volume:Float = event.value / 100.0;
-			if (audioVocalTrackGroup != null)
-				audioVocalTrackGroup.volume = volume;
-			vocalsVolumeLabel.text = 'Vocals - ${Std.int(event.value)}%';
-		});
-
-		var playbackSpeedLabel:Label = findComponent('menubarLabelPlaybackSpeed', Label);
-		addUIChangeListener('menubarItemPlaybackSpeed', (event:UIEvent) ->
-		{
-			var pitch = event.value * 2.0 / 100.0;
-			#if FLX_PITCH
-			if (audioInstTrack != null)
-				audioInstTrack.pitch = pitch;
-			if (audioVocalTrackGroup != null)
-				audioVocalTrackGroup.pitch = pitch;
-			#end
-			playbackSpeedLabel.text = 'Playback Speed - ${Std.int(event.value * 100) / 100}x';
-		});
-
-		addUIChangeListener('menubarItemToggleToolboxTools', (event:UIEvent) ->
-		{
-			ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT, event.value);
-		});
-		// setUICheckboxSelected('menubarItemToggleToolboxTools', true);
-		addUIChangeListener('menubarItemToggleToolboxNotes', (event:UIEvent) ->
-		{
-			ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value);
-		});
-		addUIChangeListener('menubarItemToggleToolboxEvents', (event:UIEvent) ->
-		{
-			ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value);
-		});
-		addUIChangeListener('menubarItemToggleToolboxDifficulty', (event:UIEvent) ->
-		{
-			ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value);
-		});
-		addUIChangeListener('menubarItemToggleToolboxMetadata', (event:UIEvent) ->
-		{
-			ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value);
-		});
-		addUIChangeListener('menubarItemToggleToolboxCharacters', (event:UIEvent) ->
-		{
-			ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT, event.value);
-		});
-		addUIChangeListener('menubarItemToggleToolboxPlayerPreview', (event:UIEvent) ->
-		{
-			ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value);
-		});
-		addUIChangeListener('menubarItemToggleToolboxOpponentPreview', (event:UIEvent) ->
-		{
-			ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value);
-		});
-
-		// TODO: Pass specific HaxeUI components to add context menus to them.
-		registerContextMenu(null, Paths.ui('chart-editor/context/test'));
-	}
-
-	/**
-	 * Setup timers and listerners to handle auto-save.
-	 */
-	function setupAutoSave()
-	{
-		WindowUtil.windowExit.add(onWindowClose);
-		saveDataDirty = false;
-	}
-
-	/**
-	 * Called after 5 minutes without saving.
-	 */
-	function autoSave()
-	{
-		saveDataDirty = false;
-
-		// Auto-save the chart.
-
-		#if html5
-		// Auto-save to local storage.
-		#else
-		// Auto-save to temp file.
-		exportAllSongData(true, true);
-		#end
-	}
-
-	function onWindowClose(exitCode:Int)
-	{
-		trace('Window exited with exit code: $exitCode');
-		trace('Should save chart? $saveDataDirty');
-
-		if (saveDataDirty)
-		{
-			exportAllSongData(true);
-		}
-	}
-
-	function cleanupAutoSave()
-	{
-		WindowUtil.windowExit.remove(onWindowClose);
-	}
-
-	public override function update(elapsed:Float)
-	{
-		// dispatchEvent gets called here.
-		super.update(elapsed);
-
-		FlxG.mouse.visible = true;
-
-		// These ones happen even if the modal dialog is open.
-		handleMusicPlayback();
-		handleNoteDisplay();
-
-		// These ones only happen if the modal dialog is not open.
-		handleScrollKeybinds();
-		// handleZoom();
-		// handleSnap();
-		handleCursor();
-
-		handleMenubar();
-		handleToolboxes();
-		handlePlaybar();
-		handlePlayhead();
-
-		handleFileKeybinds();
-		handleEditKeybinds();
-		handleViewKeybinds();
-		handleHelpKeybinds();
-
-		// DEBUG
-		#if debug
-		if (FlxG.keys.justPressed.F)
-		{
-			// This breaks the layout don't use it.
-			// showNotification('Hi there :)');
-
-			autoSave();
-		}
-
-		if (FlxG.keys.justPressed.E)
-		{
-			currentSongMetadata.timeChanges[0].timeSignatureNum = (currentSongMetadata.timeChanges[0].timeSignatureNum == 4 ? 3 : 4);
-		}
-		#end
-
-		// Right align the BF health icon.
-
-		// Base X position to the right of the grid.
-		var baseHealthIconXPos = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15;
-		// Will be 0 when not bopping. When bopping, will increase to push the icon left.
-		var healthIconOffset = healthIconBF.width - (HealthIcon.HEALTH_ICON_SIZE * 0.5);
-		healthIconBF.x = baseHealthIconXPos - healthIconOffset;
-	}
-
-	/**
-	 * Beat hit while the song is playing.
-	 */
-	override function beatHit():Bool
-	{
-		// dispatchEvent gets called here.
-		if (!super.beatHit())
-			return false;
-
-		if (shouldPlayMetronome && audioInstTrack.playing)
-		{
-			playMetronomeTick(Conductor.currentBeat % 4 == 0);
-		}
-
-		return true;
-	}
-
-	/**
-	 * Step hit while the song is playing.
-	 */
-	override function stepHit():Bool
-	{
-		// dispatchEvent gets called here.
-		if (!super.stepHit())
-			return false;
-
-		if (audioInstTrack.playing)
-		{
-			healthIconDad.onStepHit(Conductor.currentStep);
-			healthIconBF.onStepHit(Conductor.currentStep);
-		}
-
-		// if (shouldPlayMetronome)
-		// 	playMetronomeTick(false);
-
-		return true;
-	}
-
-	/**
-	 * Handle keybinds for scrolling the chart editor grid.
-	**/
-	function handleScrollKeybinds()
-	{
-		// Don't scroll when the cursor is over the UI.
-		if (isCursorOverHaxeUI)
-			return;
-
-		// Amount to scroll the grid.
-		var scrollAmount:Float = 0;
-		// Amount to scroll the playhead relative to the grid.
-		var playheadAmount:Float = 0;
-
-		// Up Arrow = Scroll Up
-		if (FlxG.keys.justPressed.UP)
-		{
-			scrollAmount = -GRID_SIZE * 0.25;
-		}
-		// Down Arrow = Scroll Down
-		if (FlxG.keys.justPressed.DOWN)
-		{
-			scrollAmount = GRID_SIZE * 0.25;
-		}
-
-		// PAGE UP = Jump Up 1 Measure
-		if (FlxG.keys.justPressed.PAGEUP)
-		{
-			scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure;
-		}
-		if (playbarButtonPressed == 'playbarBack')
-		{
-			playbarButtonPressed = '';
-			scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure;
-		}
-
-		// PAGE DOWN = Jump Down 1 Measure
-		if (FlxG.keys.justPressed.PAGEDOWN)
-		{
-			scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure;
-		}
-		if (playbarButtonPressed == 'playbarForward')
-		{
-			playbarButtonPressed = '';
-			scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure;
-		}
-
-		// Mouse Wheel = Scroll
-		if (FlxG.mouse.wheel != 0 && !FlxG.keys.pressed.CONTROL)
-		{
-			scrollAmount = -10 * FlxG.mouse.wheel;
-		}
-
-		// Middle Mouse + Drag = Scroll but move the playhead the same amount.
-		if (FlxG.mouse.pressedMiddle)
-		{
-			if (FlxG.mouse.deltaY != 0)
-			{
-				// Scroll down by the amount dragged.
-				scrollAmount += -FlxG.mouse.deltaY;
-				// Move the playhead by the same amount in the other direction so it is stationary.
-				playheadAmount += FlxG.mouse.deltaY;
-			}
-		}
-
-		// SHIFT + Scroll = Scroll Fast
-		if (FlxG.keys.pressed.SHIFT)
-		{
-			scrollAmount *= 5;
-		}
-		// CONTROL + Scroll = Scroll Precise
-		if (FlxG.keys.pressed.CONTROL)
-		{
-			scrollAmount /= 10;
-		}
-
-		// ALT = Move playhead instead.
-		if (FlxG.keys.pressed.ALT)
-		{
-			playheadAmount = scrollAmount;
-			scrollAmount = 0;
-		}
-
-		// HOME = Scroll to Top
-		if (FlxG.keys.justPressed.HOME)
-		{
-			// Scroll amount is the difference between the current position and the top.
-			scrollAmount = 0 - this.scrollPositionInPixels;
-			playheadAmount = 0 - this.playheadPositionInPixels;
-		}
-		if (playbarButtonPressed == 'playbarStart')
-		{
-			playbarButtonPressed = '';
-			scrollAmount = 0 - this.scrollPositionInPixels;
-			playheadAmount = 0 - this.playheadPositionInPixels;
-		}
-
-		// END = Scroll to Bottom
-		if (FlxG.keys.justPressed.END)
-		{
-			// Scroll amount is the difference between the current position and the bottom.
-			scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels;
-		}
-		if (playbarButtonPressed == 'playbarEnd')
-		{
-			playbarButtonPressed = '';
-			scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels;
-		}
-
-		// Apply the scroll amount.
-		this.scrollPositionInPixels += scrollAmount;
-		this.playheadPositionInPixels += playheadAmount;
-
-		// Resync the conductor and audio tracks.
-		if (scrollAmount != 0 || playheadAmount != 0)
-			moveSongToScrollPosition();
-	}
-
-	function handleZoom()
-	{
-		if (FlxG.keys.justPressed.MINUS)
-		{
-			currentZoomLevel /= 2;
-
-			// Update the grid.
-			ChartEditorThemeHandler.updateTheme(this);
-			// Update the note positions.
-			noteDisplayDirty = true;
-		}
-
-		if (FlxG.keys.justPressed.PLUS)
-		{
-			currentZoomLevel *= 2;
-
-			// Update the grid.
-			ChartEditorThemeHandler.updateTheme(this);
-			// Update the note positions.
-			noteDisplayDirty = true;
-		}
-	}
-
-	function handleSnap()
-	{
-		if (FlxG.keys.justPressed.LEFT)
-		{
-			noteSnapQuantIndex--;
-		}
-
-		if (FlxG.keys.justPressed.RIGHT)
-		{
-			noteSnapQuantIndex++;
-		}
-	}
-
-	/**
-	 * Handle display of the mouse cursor.
-	 */
-	function handleCursor()
-	{
-		// Note: If a menu is open in HaxeUI, don't handle cursor behavior.
-		var shouldHandleCursor = !isCursorOverHaxeUI || (selectionBoxStartPos != null);
-		var eventColumn = (STRUMLINE_SIZE * 2 + 1) - 1;
-
-		if (shouldHandleCursor)
-		{
-			var overlapsGrid:Bool = FlxG.mouse.overlaps(gridTiledSprite);
-
-			// Cursor position relative to the grid.
-			var cursorX:Float = FlxG.mouse.screenX - gridTiledSprite.x;
-			var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y;
-
-			var overlapsSelectionBorder = overlapsGrid
-				&& (cursorX % 40) < (GRID_SELECTION_BORDER_WIDTH / 2)
-					|| (cursorX % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2))
-						|| (cursorY % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorY % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2));
-
-			if (FlxG.mouse.justPressed)
-			{
-				if (FlxG.mouse.overlaps(gridPlayheadScrollArea))
-				{
-					gridPlayheadScrollAreaPressed = true;
-				}
-				else if (!overlapsGrid || overlapsSelectionBorder)
-				{
-					selectionBoxStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
-				}
-			}
-
-			if (gridPlayheadScrollAreaPressed)
-			{
-				Cursor.cursorMode = Grabbing;
-			}
-			else if (FlxG.mouse.overlaps(gridPlayheadScrollArea))
-			{
-				Cursor.cursorMode = Pointer;
-			}
-			else
-			{
-				Cursor.cursorMode = Default;
-			}
-
-			if (gridPlayheadScrollAreaPressed && FlxG.mouse.released)
-			{
-				gridPlayheadScrollAreaPressed = false;
-			}
-
-			if (gridPlayheadScrollAreaPressed)
-			{
-				// Clicked on the playhead scroll area.
-				// Move the playhead to the cursor position.
-				this.playheadPositionInPixels = FlxG.mouse.screenY - MENU_BAR_HEIGHT - GRID_TOP_PAD;
-				moveSongToScrollPosition();
-			}
-
-			// Cursor position snapped to the grid.
-
-			// The song position of the cursor, in steps.
-			var cursorFractionalStep:Float = cursorY / GRID_SIZE / (16 / noteSnapQuant);
-			var cursorStep:Int = Std.int(Math.floor(cursorFractionalStep));
-			var cursorMs:Float = cursorStep * Conductor.stepCrochet * (16 / noteSnapQuant);
-			// The direction value for the column at the cursor.
-			var cursorColumn:Int = Math.floor(cursorX / GRID_SIZE);
-			if (cursorColumn < 0)
-				cursorColumn = 0;
-			if (cursorColumn >= (STRUMLINE_SIZE * 2 + 1 - 1))
-			{
-				// Don't invert the event column.
-				cursorColumn = (STRUMLINE_SIZE * 2 + 1 - 1);
-			}
-			else
-			{
-				// Invert player and opponent columns.
-				if (cursorColumn >= STRUMLINE_SIZE)
-				{
-					cursorColumn -= STRUMLINE_SIZE;
-				}
-				else
-				{
-					cursorColumn += STRUMLINE_SIZE;
-				}
-			}
-
-			if (selectionBoxStartPos != null)
-			{
-				var cursorXStart:Float = selectionBoxStartPos.x - gridTiledSprite.x;
-				var cursorYStart:Float = selectionBoxStartPos.y - gridTiledSprite.y;
-
-				// Determine if we moved the mouse at all.
-				if (Math.abs(cursorX - cursorXStart) > DRAG_THRESHOLD || Math.abs(cursorY - cursorYStart) > DRAG_THRESHOLD)
-				{
-					// Handle releasing the selection box.
-					if (FlxG.mouse.justReleased)
-					{
-						// We released the mouse. Select the notes in the box.
-						var cursorFractionalStepStart:Float = cursorYStart / GRID_SIZE;
-						var cursorStepStart:Int = Math.floor(cursorFractionalStepStart);
-						var cursorMsStart:Float = cursorStepStart * Conductor.stepCrochet;
-						var cursorColumnBase:Int = Math.floor(cursorX / GRID_SIZE);
-						var cursorColumnBaseStart:Int = Math.floor(cursorXStart / GRID_SIZE);
-
-						// Since this selects based on noteData directly,
-						// we don't need to specifically exclude sustain pieces.
-
-						var notesToSelect:Array<SongNoteData> = currentSongChartNoteData;
-						notesToSelect = SongDataUtils.getNotesInTimeRange(notesToSelect, Math.min(cursorMsStart, cursorMs), Math.max(cursorMsStart, cursorMs));
-
-						// This logic is gross because the columns go 4567-0123-8.
-						// We build a list of columns to select.
-						var columnStart:Int = Std.int(Math.min(cursorColumnBase, cursorColumnBaseStart));
-						var columnEnd:Int = Std.int(Math.max(cursorColumnBase, cursorColumnBaseStart));
-						var columns:Array<Int> = [for (i in columnStart...(columnEnd + 1)) i].map(function(i:Int):Int
-						{
-							if (i >= (STRUMLINE_SIZE * 2 + 1 - 1))
-							{
-								// Don't invert the event column.
-								return (STRUMLINE_SIZE * 2 + 1 - 1);
-							}
-							else if (i >= STRUMLINE_SIZE)
-							{
-								// Invert the player columns.
-								return i - STRUMLINE_SIZE;
-							}
-							else if (i >= 0)
-							{
-								// Invert the opponent columns.
-								return i + STRUMLINE_SIZE;
-							}
-							else
-							{
-								// Minimum of 0.
-								return 0;
-							}
-						});
-						notesToSelect = SongDataUtils.getNotesWithData(notesToSelect, columns);
-
-						if (notesToSelect != null && notesToSelect.length > 0)
-						{
-							if (FlxG.keys.pressed.CONTROL)
-							{
-								// Add to the selection.
-								performCommand(new SelectNotesCommand(notesToSelect));
-							}
-							else
-							{
-								// Set the selection.
-								performCommand(new SetNoteSelectionCommand(notesToSelect, currentSelection));
-							}
-						}
-						else
-						{
-							// We made a selection box, but it didn't select anything.
-						}
-
-						// Clear the selection box.
-						selectionBoxStartPos = null;
-						setSelectionBoxBounds();
-					}
-					else
-					{
-						// Render the selection box.
-						var selectionRect = new FlxRect();
-						selectionRect.x = Math.min(FlxG.mouse.screenX, selectionBoxStartPos.x);
-						selectionRect.y = Math.min(FlxG.mouse.screenY, selectionBoxStartPos.y);
-						selectionRect.width = Math.abs(FlxG.mouse.screenX - selectionBoxStartPos.x);
-						selectionRect.height = Math.abs(FlxG.mouse.screenY - selectionBoxStartPos.y);
-						setSelectionBoxBounds(selectionRect);
-					}
-				}
-				else if (FlxG.mouse.justReleased)
-				{
-					// Clear the selection box.
-					selectionBoxStartPos = null;
-					setSelectionBoxBounds();
-
-					if (overlapsGrid)
-					{
-						// We clicked on the grid without moving the mouse.
-
-						// Find the first note that is at the cursor position.
-						var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool
-						{
-							// If note.alive is false, the note is dead and awaiting recycling.
-							return note.alive && FlxG.mouse.overlaps(note);
-						});
-
-						if (FlxG.keys.pressed.CONTROL)
-						{
-							if (highlightedNote != null)
-							{
-								// Handle the case of clicking on a sustain piece.
-								highlightedNote = highlightedNote.getBaseNoteSprite();
-								// Control click to select/deselect an individual note.
-								if (isNoteSelected(highlightedNote.noteData))
-								{
-									performCommand(new DeselectNotesCommand([highlightedNote.noteData]));
-								}
-								else
-								{
-									performCommand(new SelectNotesCommand([highlightedNote.noteData]));
-								}
-							}
-							else
-							{
-								if (highlightedNote != null)
-								{
-									// Click to select an individual note and deselect everything else.
-									if (isNoteSelected(highlightedNote.noteData))
-									{
-										performCommand(new SetNoteSelectionCommand([highlightedNote.noteData], currentSelection));
-									}
-									else
-									{
-										// Do nothing if you control-clicked on an empty space.
-									}
-								}
-							}
-						}
-						else
-						{
-							if (highlightedNote != null)
-							{
-								// Click a note to select it.
-								performCommand(new SetNoteSelectionCommand([highlightedNote.noteData], currentSelection));
-							}
-							else
-							{
-								// Click on an empty space to deselect everything.
-								// We don't place a note since this is the Select tool mode.
-								performCommand(new DeselectAllNotesCommand(currentSelection));
-							}
-						}
-					}
-					else
-					{
-						// If we clicked and released outside the grid, do nothing.
-					}
-				}
-			}
-			else if (currentPlaceNoteData != null)
-			{
-				// Handle extending the note as you drag.
-
-				// Since use Math.floor and stepCrochet here, the hold notes will be beat snapped.
-				var dragLengthSteps:Float = Math.floor((cursorMs - currentPlaceNoteData.time) / Conductor.stepCrochet);
-
-				// Without this, the newly placed note feels too short compared to the user's input.
-				var INCREMENT:Float = 1.0;
-				var dragLengthMs:Float = (dragLengthSteps + INCREMENT) * Conductor.stepCrochet;
-
-				// TODO: Add and update some sort of preview?
-
-				if (FlxG.mouse.justReleased)
-				{
-					if (dragLengthSteps > 0)
-					{
-						// Apply the new length.
-						performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, dragLengthMs));
-					}
-
-					// Finished dragging. Release the note.
-					currentPlaceNoteData = null;
-				}
-			}
-			else
-			{
-				if (FlxG.mouse.justPressed)
-				{
-					// Just clicked to place a note.
-					if (overlapsGrid && !overlapsSelectionBorder)
-					{
-						// We clicked on the grid without moving the mouse.
-
-						// Find the first note that is at the cursor position.
-						var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool
-						{
-							// If note.alive is false, the note is dead and awaiting recycling.
-							return note.alive && FlxG.mouse.overlaps(note);
-						});
-
-						if (FlxG.keys.pressed.CONTROL)
-						{
-							// Control click to select/deselect an individual note.
-							if (isNoteSelected(highlightedNote.noteData))
-							{
-								performCommand(new DeselectNotesCommand([highlightedNote.noteData]));
-							}
-							else
-							{
-								performCommand(new SelectNotesCommand([highlightedNote.noteData]));
-							}
-						}
-						else
-						{
-							if (highlightedNote != null)
-							{
-								// Click a note to select it.
-								performCommand(new SetNoteSelectionCommand([highlightedNote.noteData], currentSelection));
-							}
-							else
-							{
-								// Click a blank space to place a note and select it.
-
-								if (cursorColumn == eventColumn)
-								{
-									// Create an event and place it in the chart.
-									// TODO: Allow configuring the event to place.
-									var newEventData:SongEventData = new SongEventData(cursorMs, "test", {});
-
-									performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL));
-								}
-								else
-								{
-									// Create a note and place it in the chart.
-									var newNoteData:SongNoteData = new SongNoteData(cursorMs, cursorColumn, 0, selectedNoteKind);
-
-									performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
-
-									currentPlaceNoteData = newNoteData;
-								}
-							}
-						}
-					}
-					else
-					{
-						// If we clicked and released outside the grid, do nothing.
-					}
-				}
-
-				var rightMouseUpdated:Bool = (FlxG.mouse.justPressedRight)
-					|| (FlxG.mouse.pressedRight && (FlxG.mouse.deltaX > 0 || FlxG.mouse.deltaY > 0));
-				if (rightMouseUpdated && overlapsGrid)
-				{
-					// We right clicked on the grid.
-
-					// Find the first note that is at the cursor position.
-					var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool
-					{
-						// If note.alive is false, the note is dead and awaiting recycling.
-						return note.alive && FlxG.mouse.overlaps(note);
-					});
-
-					if (highlightedNote != null)
-					{
-						// Handle the case of clicking on a sustain piece.
-						highlightedNote = highlightedNote.getBaseNoteSprite();
-						// Remove the note.
-						performCommand(new RemoveNotesCommand([highlightedNote.noteData]));
-					}
-				}
-
-				// Handle grid cursor.
-				if (overlapsGrid && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed)
-				{
-					Cursor.cursorMode = Pointer;
-
-					// Indicate that we can pla
-					gridGhostNote.visible = (cursorColumn != eventColumn);
-
-					if (cursorColumn != gridGhostNote.noteData.data || selectedNoteKind != gridGhostNote.noteData.kind)
-					{
-						gridGhostNote.noteData.kind = selectedNoteKind;
-						gridGhostNote.noteData.data = cursorColumn;
-						gridGhostNote.playNoteAnimation();
-					}
-
-					gridGhostNote.noteData.time = cursorMs;
-					gridGhostNote.updateNotePosition(renderedNotes);
-
-					// gridCursor.visible = true;
-					// // X and Y are the cursor position relative to the grid, snapped to the top left of the grid square.
-					// gridCursor.x = Math.floor(cursorX / GRID_SIZE) * GRID_SIZE + gridTiledSprite.x + (GRID_SELECTION_BORDER_WIDTH / 2);
-					// gridCursor.y = cursorStep * GRID_SIZE + gridTiledSprite.y + (GRID_SELECTION_BORDER_WIDTH / 2);
-				}
-				else
-				{
-					gridGhostNote.visible = false;
-					Cursor.cursorMode = Default;
-				}
-			}
-		}
-		else
-		{
-			gridGhostNote.visible = false;
-		}
-
-		if (isCursorOverHaxeUIButton && Cursor.cursorMode == Default)
-		{
-			Cursor.cursorMode = Pointer;
-		}
-	}
-
-	/**
-	 * Handle using `renderedNotes` to display notes from `currentSongChartNoteData`.
-	 */
-	function handleNoteDisplay()
-	{
-		if (noteDisplayDirty)
-		{
-			noteDisplayDirty = false;
-
-			// Update for whether downscroll is enabled.
-			renderedNotes.flipX = (isViewDownscroll);
-
-			// Calculate the view bounds.
-			var viewAreaTop:Float = this.scrollPositionInPixels - GRID_TOP_PAD;
-			var viewHeight:Float = (FlxG.height - MENU_BAR_HEIGHT);
-			var viewAreaBottom:Float = this.scrollPositionInPixels + viewHeight;
-
-			// Remove notes that are no longer visible and list the ones that are.
-			var displayedNoteData:Array<SongNoteData> = [];
-			for (noteSprite in renderedNotes.members)
-			{
-				if (noteSprite == null || !noteSprite.exists || !noteSprite.visible)
-					continue;
-
-				if (!noteSprite.isNoteVisible(viewAreaBottom, viewAreaTop))
-				{
-					// This sprite is off-screen.
-					// Kill the note sprite and recycle it.
-					noteSprite.noteData = null;
-				}
-				else if (currentSongChartNoteData.indexOf(noteSprite.noteData) == -1)
-				{
-					// This note was deleted.
-					// Kill the note sprite and recycle it.
-					noteSprite.noteData = null;
-				}
-				else if (noteSprite.noteData.length > 0 && (noteSprite.parentNoteSprite == null && noteSprite.childNoteSprite == null))
-				{
-					// Note was extended.
-					// Kill the note sprite and recycle it.
-					noteSprite.noteData = null;
-				}
-				else if (noteSprite.noteData.length == 0 && (noteSprite.parentNoteSprite != null || noteSprite.childNoteSprite != null))
-				{
-					// Note was shortened.
-					// Kill the note sprite and recycle it.
-					noteSprite.noteData = null;
-				}
-				else
-				{
-					// Note is already displayed and should remain displayed.
-					displayedNoteData.push(noteSprite.noteData);
-
-					// Update the note sprite's position.
-					noteSprite.updateNotePosition(renderedNotes);
-				}
-			}
-
-			// Add notes that are now visible.
-			for (noteData in currentSongChartNoteData)
-			{
-				// Remember if we are already displaying this note.
-				if (displayedNoteData.indexOf(noteData) != -1)
-				{
-					continue;
-				}
-
-				// Get the position the note should be at.
-				var noteTimePixels:Float = noteData.time / Conductor.stepCrochet * GRID_SIZE;
-
-				// Make sure the note appears when scrolling up.
-				var modifiedViewAreaTop = viewAreaTop - GRID_SIZE;
-
-				if (noteTimePixels < modifiedViewAreaTop || noteTimePixels > viewAreaBottom)
-					continue;
-
-				// Else, this note is visible and we need to render it!
-
-				// Get a note sprite from the pool.
-				// If we can reuse a deleted note, do so.
-				// If a new note is needed, call buildNoteSprite.
-				var noteSprite:ChartEditorNoteSprite = renderedNotes.recycle(() -> new ChartEditorNoteSprite(this));
-				noteSprite.parentState = this;
-
-				// The note sprite handles animation playback and positioning.
-				noteSprite.noteData = noteData;
-
-				// Setting note data resets position relative to the grid so we fix that.
-				noteSprite.x += renderedNotes.x;
-				noteSprite.y += renderedNotes.y;
-
-				if (noteSprite.noteData.length > 0)
-				{
-					// If the note is a hold, we need to make sure it's long enough.
-					var noteLengthMs:Float = noteSprite.noteData.length;
-					var noteLengthSteps:Float = (noteLengthMs / Conductor.stepCrochet);
-					var lastNoteSprite:ChartEditorNoteSprite = noteSprite;
-
-					while (noteLengthSteps > 0)
-					{
-						if (noteLengthSteps <= 1.0)
-						{
-							// Last note in the hold.
-							// TODO: We may need to make it shorter and clip it visually.
-						}
-
-						var nextNoteSprite:ChartEditorNoteSprite = renderedNotes.recycle(ChartEditorNoteSprite);
-						nextNoteSprite.parentState = this;
-						nextNoteSprite.parentNoteSprite = lastNoteSprite;
-						lastNoteSprite.childNoteSprite = nextNoteSprite;
-
-						lastNoteSprite = nextNoteSprite;
-
-						noteLengthSteps -= 1;
-					}
-
-					// Make sure the last note sprite shows the end cap properly.
-					lastNoteSprite.childNoteSprite = null;
-
-					// var noteLengthPixels:Float = (noteLengthMs / Conductor.stepCrochet + 1) * GRID_SIZE;
-					// add(new FlxSprite(noteSprite.x, noteSprite.y - renderedNotes.y + noteLengthPixels).makeGraphic(40, 2, 0xFFFF0000));
-				}
-			}
-
-			// Destroy and recreate smaller selection squares.
-			for (member in renderedNoteSelectionSquares.members)
-			{
-				// Killing the sprite is cheap because we can recycle it.
-				member.kill();
-			}
-
-			for (noteSprite in renderedNotes.members)
-			{
-				if (isNoteSelected(noteSprite.noteData) && noteSprite.parentNoteSprite == null)
-				{
-					var selectionSquare:FlxSprite = renderedNoteSelectionSquares.recycle(() ->
-					{
-						return new FlxSprite().loadGraphic(selectionSquareBitmap);
-					});
-
-					selectionSquare.x = noteSprite.x;
-					selectionSquare.y = noteSprite.y;
-					selectionSquare.width = noteSprite.width;
-					selectionSquare.height = noteSprite.height;
-				}
-			}
-
-			// Sort the notes DESCENDING. This keeps the sustain behind the associated note.
-			renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING);
-		}
-	}
-
-	/**
-	 * Handles display elements for the playbar at the bottom.
-	 */
-	function handlePlaybar()
-	{
-		// Make sure the playbar is never nudged out of the correct spot.
-		playbarHeadLayout.x = 4;
-		playbarHeadLayout.y = FlxG.height - 48 - 8;
-
-		var songPos = Conductor.songPosition;
-		var songRemaining = songLengthInMs - songPos;
-
-		// Move the playhead to match the song position, if we aren't dragging it.
-		if (!playbarHeadDragging)
-		{
-			var songPosPercent:Float = songPos / songLengthInMs;
-			playbarHead.value = songPosPercent * 100;
-		}
-
-		var songPosSeconds:String = Std.string(Math.floor((songPos / 1000) % 60)).lpad('0', 2);
-		var songPosMinutes:String = Std.string(Math.floor((songPos / 1000) / 60)).lpad('0', 2);
-		var songPosString:String = '${songPosMinutes}:${songPosSeconds}';
-
-		setUIValue('playbarSongPos', songPosString);
-
-		var songRemainingSeconds:String = Std.string(Math.floor((songRemaining / 1000) % 60)).lpad('0', 2);
-		var songRemainingMinutes:String = Std.string(Math.floor((songRemaining / 1000) / 60)).lpad('0', 2);
-		var songRemainingString:String = '-${songRemainingMinutes}:${songRemainingSeconds}';
-
-		setUIValue('playbarSongRemaining', songRemainingString);
-	}
-
-	/**
-	 * Handle keybinds for File menu items.
-	 */
-	function handleFileKeybinds()
-	{
-		// CTRL + Q = Quit to Menu
-		if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q)
-		{
-			FlxG.switchState(new MainMenuState());
-		}
-	}
-
-	/**
-	 * Handle keybinds for edit menu items.
-	 */
-	function handleEditKeybinds()
-	{
-		// CTRL + Z = Undo
-		if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Z)
-		{
-			undoLastCommand();
-		}
-
-		if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.Z && !FlxG.keys.pressed.Y)
-		{
-			undoHeldTime += FlxG.elapsed;
-		}
-		else
-		{
-			undoHeldTime = 0;
-		}
-		if (undoHeldTime > RAPID_UNDO_DELAY + RAPID_UNDO_INTERVAL)
-		{
-			undoLastCommand();
-			undoHeldTime -= RAPID_UNDO_INTERVAL;
-		}
-
-		// CTRL + Y = Redo
-		if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Y)
-		{
-			redoLastCommand();
-		}
-
-		if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.Y && !FlxG.keys.pressed.Z)
-		{
-			redoHeldTime += FlxG.elapsed;
-		}
-		else
-		{
-			redoHeldTime = 0;
-		}
-		if (redoHeldTime > RAPID_UNDO_DELAY + RAPID_UNDO_INTERVAL)
-		{
-			redoLastCommand();
-			redoHeldTime -= RAPID_UNDO_INTERVAL;
-		}
-
-		// CTRL + C = Copy
-		if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.C)
-		{
-			// Copy selected notes.
-			// We don't need a command for this since we can't undo it.
-			SongDataUtils.writeNotesToClipboard(SongDataUtils.buildClipboard(currentSelection));
-		}
-
-		// CTRL + X = Cut
-		if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.X)
-		{
-			// Cut selected notes.
-			performCommand(new CutNotesCommand(currentSelection));
-		}
-
-		// CTRL + V = Paste
-		if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.V)
-		{
-			// Paste notes from clipboard, at the playhead.
-			performCommand(new PasteNotesCommand(scrollPositionInMs + playheadPositionInMs));
-		}
-
-		// DELETE = Delete
-		if (FlxG.keys.justPressed.DELETE)
-		{
-			// Delete selected notes.
-			performCommand(new RemoveNotesCommand(currentSelection));
-		}
-
-		// CTRL + A = Select All
-		if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.A)
-		{
-			// Select all notes.
-			performCommand(new SelectAllNotesCommand(currentSelection));
-		}
-
-		// CTRL + I = Select Inverse
-		if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.I)
-		{
-			// Select unselected notes and deselect selected notes..
-			performCommand(new InvertSelectedNotesCommand(currentSelection));
-		}
-
-		// CTRL + D = Select None
-		if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.D)
-		{
-			// Deselect all notes.
-			performCommand(new DeselectAllNotesCommand(currentSelection));
-		}
-	}
-
-	/**
-	 * Handle keybinds for View menu items.
-	 */
-	function handleViewKeybinds()
-	{
-	}
-
-	/**
-	 * Handle keybinds for Help menu items.
-	 */
-	function handleHelpKeybinds()
-	{
-		// F1 = Open Help
-		if (FlxG.keys.justPressed.F1)
-			ChartEditorDialogHandler.openUserGuideDialog(this);
-	}
-
-	function handleToolboxes()
-	{
-		handleDifficultyToolbox();
-		handlePlayerPreviewToolbox();
-		handleOpponentPreviewToolbox();
-	}
-
-	function handleDifficultyToolbox()
-	{
-		if (difficultySelectDirty)
-		{
-			difficultySelectDirty = false;
-
-			// Manage the Select Difficulty tree view.
-			var difficultyToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
-			if (difficultyToolbox == null)
-				return;
-
-			var treeView:TreeView = difficultyToolbox.findComponent('difficultyToolboxTree');
-			if (treeView == null)
-				return;
-
-			// Clear the tree view so we can rebuild it.
-			treeView.clearNodes();
-
-			var treeSong = treeView.addNode({id: 'stv_song', text: 'S: $currentSongName', icon: "haxeui-core/styles/default/haxeui_tiny.png"});
-			treeSong.expanded = true;
-
-			for (curVariation in availableVariations)
-			{
-				var variationMetadata:SongMetadata = songMetadata.get(curVariation);
-
-				var treeVariation = treeSong.addNode({
-					id: 'stv_variation_$curVariation',
-					text: 'V: ${curVariation.toTitleCase()}',
-					// icon: "haxeui-core/styles/default/haxeui_tiny.png"
-				});
-				treeVariation.expanded = true;
-
-				var difficultyList = variationMetadata.playData.difficulties;
-
-				for (difficulty in difficultyList)
-				{
-					var treeDifficulty = treeVariation.addNode({
-						id: 'stv_difficulty_${curVariation}_$difficulty',
-						text: 'D: ${difficulty.toTitleCase()}',
-						// icon: "haxeui-core/styles/default/haxeui_tiny.png"
-					});
-				}
-			}
-
-			treeView.onChange = onChangeTreeDifficulty;
-			treeView.selectedNode = getCurrentTreeDifficultyNode();
-		}
-	}
-
-	function handlePlayerPreviewToolbox()
-	{
-		// Manage the Select Difficulty tree view.
-		var charPreviewToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
-		if (charPreviewToolbox == null)
-			return;
-
-		var charPlayer:CharacterPlayer = charPreviewToolbox.findComponent('charPlayer');
-		if (charPlayer == null)
-			return;
-
-		currentPlayerCharacterPlayer = charPlayer;
-	}
-
-	function handleOpponentPreviewToolbox()
-	{
-		// Manage the Select Difficulty tree view.
-		var charPreviewToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
-		if (charPreviewToolbox == null)
-			return;
-
-		var charPlayer:CharacterPlayer = charPreviewToolbox.findComponent('charPlayer');
-		if (charPlayer == null)
-			return;
-
-		currentOpponentCharacterPlayer = charPlayer;
-	}
-
-	override function dispatchEvent(event:ScriptEvent)
-	{
-		super.dispatchEvent(event);
-
-		// We can't use the ScriptedEventDispatcher with currentCharPlayer because we can't use the IScriptedClass interface on it.
-		if (currentPlayerCharacterPlayer != null)
-		{
-			switch (event.type)
-			{
-				case ScriptEvent.UPDATE:
-					currentPlayerCharacterPlayer.onUpdate(cast event);
-				case ScriptEvent.SONG_BEAT_HIT:
-					currentPlayerCharacterPlayer.onBeatHit(cast event);
-				case ScriptEvent.SONG_STEP_HIT:
-					currentPlayerCharacterPlayer.onStepHit(cast event);
-				case ScriptEvent.NOTE_HIT:
-					currentPlayerCharacterPlayer.onNoteHit(cast event);
-			}
-		}
-
-		if (currentOpponentCharacterPlayer != null)
-		{
-			switch (event.type)
-			{
-				case ScriptEvent.UPDATE:
-					currentOpponentCharacterPlayer.onUpdate(cast event);
-				case ScriptEvent.SONG_BEAT_HIT:
-					currentOpponentCharacterPlayer.onBeatHit(cast event);
-				case ScriptEvent.SONG_STEP_HIT:
-					currentOpponentCharacterPlayer.onStepHit(cast event);
-				case ScriptEvent.NOTE_HIT:
-					currentOpponentCharacterPlayer.onNoteHit(cast event);
-			}
-		}
-	}
-
-	function getCurrentTreeDifficultyNode():TreeViewNode
-	{
-		var treeView:TreeView = findComponent('difficultyToolboxTree');
-
-		if (treeView == null)
-			return null;
-
-		var result = treeView.findNodeByPath('stv_song/stv_variation_$selectedVariation/stv_difficulty_${selectedVariation}_$selectedDifficulty', 'id');
-
-		if (result == null)
-			return null;
-
-		return result;
-	}
-
-	function onChangeTreeDifficulty(event:UIEvent):Void
-	{
-		// Get the newly selected node.
-		var treeView:TreeView = cast event.target;
-		var targetNode:TreeViewNode = treeView.selectedNode;
-
-		if (targetNode == null)
-		{
-			trace('No target node!');
-			// Reset the user's selection.
-			treeView.selectedNode = getCurrentTreeDifficultyNode();
-			return;
-		}
-
-		switch (targetNode.data.id.split('_')[1])
-		{
-			case 'difficulty':
-				var variation = targetNode.data.id.split('_')[2];
-				var difficulty = targetNode.data.id.split('_')[3];
-
-				if (variation != null && difficulty != null)
-				{
-					trace('Changing difficulty to $variation:$difficulty');
-					selectedVariation = variation;
-					selectedDifficulty = difficulty;
-				}
-			// case 'song':
-			// case 'variation':
-			default:
-				// Reset the user's selection.
-				trace('Selected wrong node type, resetting selection.');
-				treeView.selectedNode = getCurrentTreeDifficultyNode();
-		}
-	}
-
-	function addDifficulty(variation:String)
-	{
-	}
-
-	function addVariation(variationId:String)
-	{
-		// Create a new variation with the specified ID.
-		songMetadata.set(variationId, currentSongMetadata.clone(variationId));
-		// Switch to the new variation.
-		selectedVariation = variationId;
-	}
-
-	/**
-	 * Handle the player preview/gameplay test area on the left side.
-	 */
-	function handlePlayerDisplay()
-	{
-	}
-
-	/**
-	 * Handles the note preview/scroll area on the right side.
-	 * Notes are rendered here as small bars.
-	 * This function also handles:
-	 * - Moving the viewport preview box around based on its current position.
-	 * - Scrolling the note preview area down if the note preview is taller than the screen,
-	 *   and the viewport nears the end of the visible area.
-	 */
-	function handleNotePreview()
-	{
-		//
-		if (notePreviewDirty)
-		{
-			notePreviewDirty = false;
-
-			var PREVIEW_WIDTH:Int = GRID_SIZE * 2;
-			var STEP_HEIGHT:Int = 1;
-			var PREVIEW_HEIGHT:Int = Std.int(Conductor.getTimeInSteps(audioInstTrack.length) * STEP_HEIGHT);
-
-			notePreviewBitmap = new BitmapData(PREVIEW_WIDTH, PREVIEW_HEIGHT, true);
-			notePreviewBitmap.fillRect(new Rectangle(0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT), PREVIEW_BG_COLOR);
-		}
-	}
-
-	/**
-	 * Perform a spot update on the note preview, by editing the note preview
-	 * only where necessary. More efficient than a full update.
-	 */
-	function updateNotePreview(note:SongNoteData, ?deleteNote:Bool = false)
-	{
-	}
-
-	/**
-	 * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status.
-	 * Does not handle onClick ACTIONS of the menubar.
-	 */
-	function handleMenubar()
-	{
-		if (commandHistoryDirty)
-		{
-			commandHistoryDirty = false;
-
-			// Update the Undo and Redo buttons.
-			var undoButton:MenuItem = findComponent('menubarItemUndo', MenuItem);
-
-			if (undoButton != null)
-			{
-				if (undoHistory.length == 0)
-				{
-					// Disable the Undo button.
-					undoButton.disabled = true;
-					undoButton.text = "Undo";
-				}
-				else
-				{
-					// Change the label to the last command.
-					undoButton.disabled = false;
-					undoButton.text = 'Undo ${undoHistory[undoHistory.length - 1].toString()}';
-				}
-			}
-			else
-			{
-				trace("undoButton is null");
-			}
-
-			var redoButton:MenuItem = findComponent('menubarItemRedo', MenuItem);
-
-			if (redoButton != null)
-			{
-				if (redoHistory.length == 0)
-				{
-					// Disable the Redo button.
-					redoButton.disabled = true;
-					redoButton.text = "Redo";
-				}
-				else
-				{
-					// Change the label to the last command.
-					redoButton.disabled = false;
-					redoButton.text = 'Redo ${redoHistory[redoHistory.length - 1].toString()}';
-				}
-			}
-			else
-			{
-				trace("redoButton is null");
-			}
-		}
-	}
-
-	/**
-	 * Handle syncronizing the conductor with the music playback.
-	 */
-	function handleMusicPlayback()
-	{
-		if (audioInstTrack != null && audioInstTrack.playing)
-		{
-			if (FlxG.mouse.pressedMiddle)
-			{
-				// If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat!
-
-				var oldStepTime = Conductor.currentStepTime;
-				var oldSongPosition = Conductor.songPosition;
-				Conductor.update(audioInstTrack.time);
-				handleHitsounds(oldSongPosition, Conductor.songPosition);
-				// Resync vocals.
-				if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
-					audioVocalTrackGroup.time = audioInstTrack.time;
-				var diffStepTime = Conductor.currentStepTime - oldStepTime;
-
-				// Move the playhead.
-				playheadPositionInPixels += diffStepTime * GRID_SIZE;
-
-				// We don't move the song to scroll position, or update the note sprites.
-			}
-			else
-			{
-				// Else, move the entire view.
-				var oldSongPosition = Conductor.songPosition;
-				Conductor.update(audioInstTrack.time);
-				handleHitsounds(oldSongPosition, Conductor.songPosition);
-				// Resync vocals.
-				if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
-					audioVocalTrackGroup.time = audioInstTrack.time;
-
-				// We need time in fractional steps here to allow the song to actually play.
-				// Also account for a potentially offset playhead.
-				scrollPositionInPixels = Conductor.currentStepTime * GRID_SIZE - playheadPositionInPixels;
-
-				// DO NOT move song to scroll position here specifically.
-
-				// We need to update the note sprites.
-				noteDisplayDirty = true;
-			}
-		}
-
-		if (FlxG.keys.justPressed.SPACE && !isHaxeUIDialogOpen)
-		{
-			toggleAudioPlayback();
-		}
-	}
-
-	/**
-	 * Handle the playback of hitsounds.
-	 */
-	function handleHitsounds(oldSongPosition:Float, newSongPosition:Float):Void
-	{
-		if (!hitsoundsEnabled)
-			return;
-
-		// Assume notes are sorted by time.
-		for (noteData in currentSongChartNoteData)
-		{
-			if (noteData.time < oldSongPosition)
-				// Note is in the past.
-				continue;
-
-			if (noteData.time >= newSongPosition)
-				// Note is in the future.
-				return;
-
-			// Note was just hit.
-
-			// Character preview.
-
-			// Why does NOTESCRIPTEVENT TAKE A SPRITE AAAAA
-			var tempNote:Note = new Note(noteData.time, noteData.data, null, false, NORMAL);
-			tempNote.mustPress = noteData.getMustHitNote();
-			tempNote.data.sustainLength = noteData.length;
-			tempNote.data.noteKind = noteData.kind;
-			tempNote.scrollFactor.set(0, 0);
-			var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, tempNote, 1, true);
-			dispatchEvent(event);
-
-			// Calling event.cancelEvent() skips all the other logic! Neat!
-			if (event.eventCanceled)
-				continue;
-
-			// Hitsounds.
-			switch (noteData.getStrumlineIndex())
-			{
-				case 0: // Player
-					if (hitsoundsEnabledPlayer)
-						playSound(Paths.sound('funnyNoise/funnyNoise-09'));
-				case 1: // Opponent
-					if (hitsoundsEnabledOpponent)
-						playSound(Paths.sound('funnyNoise/funnyNoise-010'));
-			}
-		}
-	}
-
-	function startAudioPlayback()
-	{
-		if (audioInstTrack != null)
-			audioInstTrack.play();
-		if (audioVocalTrackGroup != null)
-			audioVocalTrackGroup.play();
-		if (audioVocalTrackGroup != null)
-			audioVocalTrackGroup.play();
-	}
-
-	function stopAudioPlayback()
-	{
-		if (audioInstTrack != null)
-			audioInstTrack.pause();
-		if (audioVocalTrackGroup != null)
-			audioVocalTrackGroup.pause();
-		if (audioVocalTrackGroup != null)
-			audioVocalTrackGroup.pause();
-	}
-
-	function toggleAudioPlayback()
-	{
-		if (audioInstTrack == null)
-			return;
-
-		if (audioInstTrack.playing)
-		{
-			stopAudioPlayback();
-		}
-		else
-		{
-			startAudioPlayback();
-		}
-	}
-
-	function handlePlayhead()
-	{
-		// Place notes at the playhead.
-		// TODO: Add the ability to switch modes.
-		if (true)
-		{
-			if (FlxG.keys.justPressed.ONE)
-				placeNoteAtPlayhead(0);
-			if (FlxG.keys.justPressed.TWO)
-				placeNoteAtPlayhead(1);
-			if (FlxG.keys.justPressed.THREE)
-				placeNoteAtPlayhead(2);
-			if (FlxG.keys.justPressed.FOUR)
-				placeNoteAtPlayhead(3);
-			if (FlxG.keys.justPressed.FIVE)
-				placeNoteAtPlayhead(4);
-			if (FlxG.keys.justPressed.SIX)
-				placeNoteAtPlayhead(5);
-			if (FlxG.keys.justPressed.SEVEN)
-				placeNoteAtPlayhead(6);
-			if (FlxG.keys.justPressed.EIGHT)
-				placeNoteAtPlayhead(7);
-		}
-	}
-
-	function placeNoteAtPlayhead(column:Int):Void
-	{
-		var gridSnappedPlayheadPos = scrollPositionInPixels - (scrollPositionInPixels % GRID_SIZE);
-	}
-
-	function set_scrollPositionInPixels(value:Float):Float
-	{
-		if (value < 0)
-		{
-			// If we're scrolling up, and we hit the top,
-			// but the playhead is in the middle, move the playhead up.
-			if (playheadPositionInPixels > 0)
-			{
-				var amount = scrollPositionInPixels - value;
-				playheadPositionInPixels -= amount;
-			}
-
-			value = 0;
-		}
-
-		if (value > songLengthInPixels)
-			value = songLengthInPixels;
-
-		if (value == scrollPositionInPixels)
-			return value;
-
-		this.scrollPositionInPixels = value;
-
-		// Move the grid sprite to the correct position.
-		if (isViewDownscroll)
-		{
-			gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
-		}
-		else
-		{
-			gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
-		}
-		// Move the rendered notes to the correct position.
-		renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y);
-		renderedNoteSelectionSquares.setPosition(renderedNotes.x, renderedNotes.y);
-		if (gridSpectrogram != null)
-		{
-			// Move the spectrogram to the correct position.
-			gridSpectrogram.y = gridTiledSprite.y;
-			gridSpectrogram.setPosition(0, 0);
-		}
-
-		return this.scrollPositionInPixels;
-	}
-
-	function get_playheadPositionInPixels():Float
-	{
-		return this.playheadPositionInPixels;
-	}
-
-	function set_playheadPositionInPixels(value:Float):Float
-	{
-		// Make sure playhead doesn't go outside the song.
-		if (value + scrollPositionInPixels < 0)
-			value = -scrollPositionInPixels;
-		if (value + scrollPositionInPixels > songLengthInPixels)
-			value = songLengthInPixels - scrollPositionInPixels;
-
-		this.playheadPositionInPixels = value;
-
-		// Move the playhead sprite to the correct position.
-		gridPlayhead.y = this.playheadPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
-
-		return this.playheadPositionInPixels;
-	}
-
-	/**
-	 * Loads an instrumental from an absolute file path, replacing the current instrumental.
-	 */
-	public function loadInstrumentalFromPath(path:String):Void
-	{
-		#if sys
-		var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path);
-		loadInstrumentalFromBytes(fileBytes);
-		#else
-		trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
-		#end
-	}
-
-	/**
-	 * Loads an instrumental from audio byte data, replacing the current instrumental.
-	 */
-	public function loadInstrumentalFromBytes(bytes:haxe.io.Bytes):Void
-	{
-		var openflSound = new openfl.media.Sound();
-		openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length);
-		audioInstTrack = FlxG.sound.load(openflSound, 1.0, false);
-		audioInstTrack.autoDestroy = false;
-		audioInstTrack.pause();
-
-		// Tell the user the load was successful.
-		// TODO: Un-bork this.
-		// showNotification('Loaded instrumental track successfully.');
-
-		postLoadInstrumental();
-	}
-
-	public function loadInstrumentalFromAsset(path:String):Void
-	{
-		var instTrack = FlxG.sound.load(path, 1.0, false);
-		audioInstTrack = instTrack;
-
-		postLoadInstrumental();
-	}
-
-	function postLoadInstrumental()
-	{
-		// Prevent the time from skipping back to 0 when the song ends.
-		audioInstTrack.onComplete = function()
-		{
-			if (audioInstTrack != null)
-				audioInstTrack.pause();
-			if (audioVocalTrackGroup != null)
-				audioVocalTrackGroup.pause();
-		};
-
-		songLengthInMs = audioInstTrack.length;
-
-		gridTiledSprite.height = songLengthInPixels;
-		if (gridSpectrogram != null)
-		{
-			gridSpectrogram.setSound(audioInstTrack);
-			gridSpectrogram.generateSection(0, songLengthInMs / 1000);
-		}
-
-		scrollPositionInPixels = 0;
-		playheadPositionInPixels = 0;
-		moveSongToScrollPosition();
-	}
-
-	/**
-	 * Loads a vocal track from an absolute file path.
-	 */
-	public function loadVocalsFromPath(path:String):Void
-	{
-		#if sys
-		var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path);
-		loadVocalsFromBytes(fileBytes);
-		#else
-		trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
-		#end
-	}
-
-	public function loadVocalsFromAsset(path:String):Void
-	{
-		var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
-		audioVocalTrackGroup.add(vocalTrack);
-	}
-
-	/**
-	 * Loads a vocal track from audio byte data.
-	 */
-	public function loadVocalsFromBytes(bytes:haxe.io.Bytes):Void
-	{
-		var openflSound = new openfl.media.Sound();
-		openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length);
-		var vocalTrack:FlxSound = FlxG.sound.load(openflSound, 1.0, false);
-		audioVocalTrackGroup.add(vocalTrack);
-
-		// Tell the user the load was successful.
-		// TODO: Un-bork this.
-		// showNotification('Loaded instrumental track successfully.');
-	}
-
-	/**
-	 * Fetch's a song's existing chart and audio and loads it, replacing the current song.
-	 */
-	public function loadSongAsTemplate(songId:String)
-	{
-		var song:Song = SongDataParser.fetchSong(songId);
-
-		if (song == null)
-		{
-			// showNotification('Failed to load song template.');
-			return;
-		}
-
-		// Load the song metadata.
-		var rawSongMetadata:Array<SongMetadata> = song.getRawMetadata();
-
-		this.songMetadata = new Map<String, SongMetadata>();
-
-		for (metadata in rawSongMetadata)
-		{
-			var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation;
-			this.songMetadata.set(variation, metadata);
-		}
-
-		this.songChartData = new Map<String, SongChartData>();
-
-		for (metadata in rawSongMetadata)
-		{
-			var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation;
-			this.songChartData.set(variation, SongDataParser.parseSongChartData(songId, metadata.variation));
-		}
-
-		Conductor.forceBPM(null); // Disable the forced BPM.
-		Conductor.mapTimeChanges(currentSongMetadata.timeChanges);
-
-		loadInstrumentalFromAsset(Paths.inst(songId));
-		loadVocalsFromAsset(Paths.voices(songId));
-
-		// showNotification('Loaded song ${songId}.');
-	}
-
-	/**
-	 * When setting the scroll position, except when automatically scrolling during song playback,
-	 * we need to update the conductor's current step time and the timestamp of the audio tracks.
-	 */
-	function moveSongToScrollPosition()
-	{
-		// Update the songPosition in the Conductor.
-		Conductor.update(scrollPositionInMs);
-
-		// Update the songPosition in the audio tracks.
-		if (audioInstTrack != null)
-			audioInstTrack.time = scrollPositionInMs + playheadPositionInMs;
-		if (audioVocalTrackGroup != null)
-			audioVocalTrackGroup.time = scrollPositionInMs + playheadPositionInMs;
-
-		// We need to update the note sprites because we changed the scroll position.
-		noteDisplayDirty = true;
-	}
-
-	/**
-	 * Perform (or redo) a command, then add it to the undo stack.
-	 * 
-	 * @param command The command to perform.
-	 * @param purgeRedoStack If true, the redo stack will be cleared.
-	 */
-	function performCommand(command:ChartEditorCommand, ?purgeRedoStack:Bool = true):Void
-	{
-		command.execute(this);
-		undoHistory.push(command);
-		commandHistoryDirty = true;
-		if (purgeRedoStack)
-			redoHistory = [];
-	}
-
-	/**
-	 * Undo a command, then add it to the redo stack.
-	 * @param command The command to undo.
-	 */
-	function undoCommand(command:ChartEditorCommand):Void
-	{
-		command.undo(this);
-		redoHistory.push(command);
-		commandHistoryDirty = true;
-	}
-
-	/**
-	 * Undo the last command in the undo stack, then add it to the redo stack.
-	 */
-	function undoLastCommand():Void
-	{
-		if (undoHistory.length == 0)
-		{
-			trace('No actions to undo.');
-			return;
-		}
-
-		var command = undoHistory.pop();
-		undoCommand(command);
-	}
-
-	/**
-	 * Redo the last command in the redo stack, then add it to the undo stack.
-	 */
-	function redoLastCommand():Void
-	{
-		if (redoHistory.length == 0)
-		{
-			trace('No actions to redo.');
-			return;
-		}
-
-		var command = redoHistory.pop();
-		performCommand(command, false);
-	}
-
-	function sortChartData()
-	{
-		currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int
-		{
-			return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
-		});
-
-		currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int
-		{
-			return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
-		});
-	}
-
-	function playMetronomeTick(?high:Bool = false)
-	{
-		playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}'));
-	}
-
-	function isNoteSelected(note:SongNoteData):Bool
-	{
-		return currentSelection.indexOf(note) != -1;
-	}
-
-	/**
-	 * Play a sound effect.
-	 * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance.
-	 */
-	function playSound(path:String)
-	{
-		var snd:FlxSound = FlxG.sound.list.recycle(FlxSound);
-		snd.loadEmbedded(FlxG.sound.cache(path));
-		snd.autoDestroy = true;
-		FlxG.sound.list.add(snd);
-		snd.play();
-	}
-
-	override function destroy()
-	{
-		super.destroy();
-
-		cleanupAutoSave();
-
-		@:privateAccess
-		ChartEditorNoteSprite.noteFrameCollection = null;
-	}
-
-	/**
-	 * Displays a notification to the user. The only action is to dismiss.
-	 */
-	function showNotification(text:String)
-	{
-		var notifBarText:Label = notifBar.findComponent('notifBarText', Label);
-		var notifBarAction1:Button = notifBar.findComponent('notifBarAction1', Button);
-
-		// Make it appear.
-		notifBar.show();
-
-		// Don't shift the UI up.
-		notifBar.method = "float";
-		// Anchor to far right.
-		notifBar.x = FlxG.width - notifBar.width;
-
-		// Set the message.
-		notifBarText.text = text;
-
-		// Configure the action button.
-		notifBarAction1.text = 'Dismiss';
-		notifBarAction1.onClick = (_:UIEvent) -> dismissNotification();
-
-		// Auto dismiss.
-		new FlxTimer().start(NOTIFICATION_DISMISS_TIME, (_:FlxTimer) -> dismissNotification());
-	}
-
-	/**
-	 * Dismiss any existing notifications, if there are any.
-	 */
-	function dismissNotification():Void
-	{
-		notifBar.hide();
-	}
-
-	/**
-	 * @param force Whether to force the export without prompting the user for a file location.
-	 * @param tmp If true, save to the temporary directory instead of the local `backup` directory.
-	 */
-	public function exportAllSongData(?force:Bool = false, ?tmp:Bool = false):Void
-	{
-		var zipEntries = [];
-
-		for (variation in availableVariations)
-		{
-			var variationId = variation;
-			if (variation == '' || variation == 'default' || variation == 'normal')
-			{
-				variationId = '';
-			}
-
-			if (variationId == '')
-			{
-				var variationMetadata = songMetadata.get(variation);
-				zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata.json', SerializerUtil.toJSON(variationMetadata)));
-				var variationChart = songChartData.get(variation);
-				zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart.json', SerializerUtil.toJSON(variationChart)));
-			}
-			else
-			{
-				var variationMetadata = songMetadata.get(variation);
-				zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata-$variationId.json', SerializerUtil.toJSON(variationMetadata)));
-				var variationChart = songChartData.get(variation);
-				zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart-$variationId.json', SerializerUtil.toJSON(variationChart)));
-			}
-		}
-
-		// TODO: Add audio files to the ZIP.
-
-		trace('Exporting ${zipEntries.length} files to ZIP...');
-
-		if (force)
-		{
-			var targetPath:String = tmp ? Path.join([FileUtil.getTempDir(), 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']) : Path.join(['./backups/', 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']);
-
-			// We have to force write because the program will die before the save dialog is closed.
-			trace('Force exporting to $targetPath...');
-			FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath);
-			return;
-		}
-
-		// Prompt and save.
-		var onSave:Array<String>->Void = (paths:Array<String>) ->
-		{
-			trace('Successfully exported files.');
-		};
-
-		var onCancel:Void->Void = () ->
-		{
-			trace('Export cancelled.');
-		};
-
-		FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '$currentSongId-chart.zip');
-	}
+  /**
+   * CONSTANTS
+   */
+  // ==============================
+  // XML Layouts
+  static final CHART_EDITOR_LAYOUT = Paths.ui('chart-editor/main-view');
+
+  static final CHART_EDITOR_NOTIFBAR_LAYOUT = Paths.ui('chart-editor/components/notifbar');
+  static final CHART_EDITOR_PLAYBARHEAD_LAYOUT = Paths.ui('chart-editor/components/playbar-head');
+
+  static final CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT = Paths.ui('chart-editor/toolbox/tools');
+  static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT = Paths.ui('chart-editor/toolbox/notedata');
+  static final CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT = Paths.ui('chart-editor/toolbox/eventdata');
+  static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT = Paths.ui('chart-editor/toolbox/metadata');
+  static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT = Paths.ui('chart-editor/toolbox/difficulty');
+  static final CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT = Paths.ui('chart-editor/toolbox/characters');
+  static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/player-preview');
+  static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/opponent-preview');
+
+  /**
+   * The base grid size for the chart editor.
+   */
+  public static final GRID_SIZE:Int = 40;
+
+  public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = 12;
+
+  public static final PLAYHEAD_HEIGHT:Int = Std.int(GRID_SIZE / 8);
+
+  public static final GRID_SELECTION_BORDER_WIDTH:Int = 6;
+
+  /**
+   * Number of notes in each player's strumline.
+   */
+  public static final STRUMLINE_SIZE = 4;
+
+  /**
+   * The height of the menu bar in the layout.
+   */
+  static final MENU_BAR_HEIGHT = 32;
+
+  /**
+   * Duration to wait before autosaving the chart.
+   */
+  static final AUTOSAVE_TIMER_DELAY:Float = 60.0 * 5.0;
+
+  /**
+   * The amount of padding between the menu bar and the chart grid when fully scrolled up.
+   */
+  static final GRID_TOP_PAD:Int = 8;
+
+  /**
+   * Duration, in seconds, until toast notifications are automatically hidden.
+   */
+  static final NOTIFICATION_DISMISS_TIME:Float = 3.0;
+
+  // Start performing rapid undo after this many seconds.
+  static final RAPID_UNDO_DELAY:Float = 0.4;
+  // Perform a rapid undo every this many seconds.
+  static final RAPID_UNDO_INTERVAL:Float = 0.1;
+
+  // UI Element Colors
+  // Background color tint.
+  static final CURSOR_COLOR:FlxColor = 0xE0FFFFFF;
+  static final PREVIEW_BG_COLOR:FlxColor = 0xFF303030;
+  static final PLAYHEAD_SCROLL_AREA_COLOR:FlxColor = 0xFF682B2F;
+  static final SPECTROGRAM_COLOR:FlxColor = 0xFFFF0000;
+  static final PLAYHEAD_COLOR:FlxColor = 0xC0BD0231;
+
+  /**
+   * How many pixels far the user needs to move the mouse before the cursor is considered to be dragged rather than clicked.
+   */
+  static final DRAG_THRESHOLD:Float = 16.0;
+
+  /**
+   * Types of notes you can snap to.
+   */
+  static final SNAP_QUANTS:Array<Int> = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96, 192];
+
+  /**
+   * INSTANCE DATA
+   */
+  // ==============================
+  public var currentZoomLevel:Float = 1.0;
+
+  var noteSnapQuantIndex:Int = 3;
+
+  public var noteSnapQuant(get, never):Int;
+
+  function get_noteSnapQuant():Int
+  {
+    return SNAP_QUANTS[noteSnapQuantIndex];
+  }
+
+  /**
+   * scrollPosition is the current position in the song, in pixels.
+   * One pixel is 1/40 of 1 step, and 1/160 of 1 beat.
+   */
+  var scrollPositionInPixels(default, set):Float = -1.0;
+
+  /**
+   * scrollPosition, converted to steps.
+   * TODO: Handle BPM changes.
+   */
+  var scrollPositionInSteps(get, null):Float;
+
+  function get_scrollPositionInSteps():Float
+  {
+    return scrollPositionInPixels / GRID_SIZE;
+  }
+
+  /**
+   * scrollPosition, converted to milliseconds.
+   * TODO: Handle BPM changes.
+   */
+  var scrollPositionInMs(get, set):Float;
+
+  function get_scrollPositionInMs():Float
+  {
+    return scrollPositionInSteps * Conductor.stepCrochet;
+  }
+
+  function set_scrollPositionInMs(value:Float):Float
+  {
+    scrollPositionInPixels = value / Conductor.stepCrochet;
+    return value;
+  }
+
+  /**
+   * The position of the playhead, in pixels, relative to the scrollPosition.
+   * 0 means playhead is at the top of the grid.
+   * 40 means the playhead is 1 grid length below the base position.
+   * -40 means the playhead is 1 grid length above the base position.
+   */
+  var playheadPositionInPixels(default, set):Float;
+
+  var playheadPositionInSteps(get, null):Float;
+
+  /**
+   * playheadPosition, converted to steps.
+   */
+  function get_playheadPositionInSteps():Float
+  {
+    return playheadPositionInPixels / GRID_SIZE;
+  }
+
+  /**
+   * playheadPosition, converted to milliseconds.
+   */
+  var playheadPositionInMs(get, null):Float;
+
+  function get_playheadPositionInMs():Float
+  {
+    return playheadPositionInSteps * Conductor.stepCrochet;
+  }
+
+  /**
+   * This is the song's length in PIXELS, same format as scrollPosition.
+   */
+  var songLengthInPixels(get, default):Int;
+
+  function get_songLengthInPixels():Int
+  {
+    if (songLengthInPixels <= 0)
+      return 1000;
+
+    return songLengthInPixels;
+  }
+
+  /**
+   * songLength, converted to steps.
+   * TODO: Handle BPM changes.
+   */
+  var songLengthInSteps(get, set):Float;
+
+  function get_songLengthInSteps():Float
+  {
+    return songLengthInPixels / GRID_SIZE;
+  }
+
+  function set_songLengthInSteps(value:Float):Float
+  {
+    songLengthInPixels = Std.int(value * GRID_SIZE);
+    return value;
+  }
+
+  /**
+   * songLength, converted to milliseconds.
+   * TODO: Handle BPM changes.
+   */
+  var songLengthInMs(get, set):Float;
+
+  function get_songLengthInMs():Float
+  {
+    return songLengthInSteps * Conductor.stepCrochet;
+  }
+
+  function set_songLengthInMs(value:Float):Float
+  {
+    songLengthInSteps = Conductor.getTimeInSteps(audioInstTrack.length);
+    return value;
+  }
+
+  var currentTheme(default, set):ChartEditorTheme = null;
+
+  function set_currentTheme(value:ChartEditorTheme):ChartEditorTheme
+  {
+    if (value == null || value == currentTheme)
+      return currentTheme;
+
+    currentTheme = value;
+    ChartEditorThemeHandler.updateTheme(this);
+    return value;
+  }
+
+  /**
+   * Whether a skip button has been pressed on the playbar, and which one.
+   * This will be used to update the scrollPosition (in the same function that handles the scroll wheel), then cleared.
+   */
+  var playbarButtonPressed:String = null;
+
+  /**
+   * Whether the head of the playbar is currently being dragged with the mouse by the user.
+   */
+  var playbarHeadDragging:Bool = false;
+
+  /**
+   * Whether music was playing before we started dragging the playbar head.
+   * If so, then when we stop dragging the playbar head, we should resume song playback.
+   */
+  var playbarHeadDraggingWasPlaying:Bool = false;
+
+  /**
+   * The note kind to use for notes being placed in the chart. Defaults to `''`.
+   */
+  var selectedNoteKind:String = '';
+
+  /**
+   * The note kind to use for notes being placed in the chart. Defaults to `''`.
+   */
+  var selectedEventKind:String = 'FocusCamera';
+
+  /**
+   * The note data as a struct.
+   */
+  var selectedEventData:DynamicAccess<Dynamic> = {};
+
+  /**
+   * Whether to play a metronome sound while the playhead is moving.
+   */
+  var shouldPlayMetronome:Bool = true;
+
+  /**
+   * Use the tool window to affect how the user interacts with the program.
+   */
+  var currentToolMode:ChartEditorToolMode = ChartEditorToolMode.Select;
+
+  /**
+   * The character sprite in the Player Preview window.
+   */
+  var currentPlayerCharacterPlayer:CharacterPlayer = null;
+
+  /**
+   * The character sprite in the Opponent Preview window.
+   */
+  var currentOpponentCharacterPlayer:CharacterPlayer = null;
+
+  /**
+   * Whether the current view is in downscroll mode.
+   */
+  var isViewDownscroll(default, set):Bool = false;
+
+  function set_isViewDownscroll(value:Bool):Bool
+  {
+    isViewDownscroll = value;
+
+    // Make sure view is updated when we change view modes.
+    noteDisplayDirty = true;
+    notePreviewDirty = true;
+    this.scrollPositionInPixels = this.scrollPositionInPixels;
+
+    return isViewDownscroll;
+  }
+
+  /**
+   * Whether hitsounds are enabled for at least one character.
+   */
+  var hitsoundsEnabled(get, null):Bool;
+
+  function get_hitsoundsEnabled():Bool
+  {
+    return hitsoundsEnabledPlayer || hitsoundsEnabledOpponent;
+  }
+
+  /**
+   * Whether hitsounds are enabled for the player.
+   */
+  var hitsoundsEnabledPlayer:Bool = true;
+
+  /**
+   * Whether hitsounds are enabled for the opponent.
+   */
+  var hitsoundsEnabledOpponent:Bool = true;
+
+  /**
+   * Whether the user's mouse cursor is hovering over a SOLID component of the HaxeUI.
+   * If so, ignore mouse events underneath.
+   */
+  var isCursorOverHaxeUI(get, null):Bool;
+
+  function get_isCursorOverHaxeUI():Bool
+  {
+    return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
+  }
+
+  var isCursorOverHaxeUIButton(get, null):Bool;
+
+  function get_isCursorOverHaxeUIButton():Bool
+  {
+    return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Button)
+      || Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Link);
+  }
+
+  /**
+   * Set by ChartEditorDialogHandler, used to prevent background interaction while the dialog is open.
+   */
+  public var isHaxeUIDialogOpen:Bool = false;
+
+  /**
+   * The variation ID for the difficulty which is currently being edited.
+   */
+  var selectedVariation(default, set):String = Constants.DEFAULT_VARIATION;
+
+  function set_selectedVariation(value:String):String
+  {
+    selectedVariation = value;
+
+    // Make sure view is updated when the variation changes.
+    noteDisplayDirty = true;
+    notePreviewDirty = true;
+
+    return selectedVariation;
+  }
+
+  /**
+   * The difficulty ID for the difficulty which is currently being edited.
+   */
+  var selectedDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY;
+
+  function set_selectedDifficulty(value:String):String
+  {
+    selectedDifficulty = value;
+
+    // Make sure view is updated when the difficulty changes.
+    noteDisplayDirty = true;
+    notePreviewDirty = true;
+
+    return selectedDifficulty;
+  }
+
+  /**
+   * Whether the user is currently in Pattern Mode.
+   * This overrides the chart editor's normal behavior.
+   */
+  var isInPatternMode(default, set):Bool = false;
+
+  function set_isInPatternMode(value:Bool):Bool
+  {
+    isInPatternMode = value;
+
+    // Make sure view is updated when we change modes.
+    noteDisplayDirty = true;
+    notePreviewDirty = true;
+    this.scrollPositionInPixels = 0;
+
+    return isInPatternMode;
+  }
+
+  var currentPattern:String = '';
+
+  /**
+   * Whether the note display render group has been modified and needs to be updated.
+   * This happens when we scroll or add/remove notes, and need to update what notes are displayed and where.
+   */
+  var noteDisplayDirty:Bool = true;
+
+  /**
+   * Whether the note preview graphic needs to be FULLY rebuilt.
+   * The Bitmap can be modified by individual commands without using this.
+   */
+  var notePreviewDirty:Bool = true;
+
+  /**
+   * Whether the chart has been modified since it was last saved.
+   * Used to determine whether to auto-save, etc.
+   */
+  var saveDataDirty(default, set):Bool = false;
+
+  function set_saveDataDirty(value:Bool):Bool
+  {
+    if (value == saveDataDirty)
+      return value;
+
+    if (value)
+    {
+      // Start the auto-save timer.
+      autoSaveTimer = new FlxTimer().start(AUTOSAVE_TIMER_DELAY, (_) -> autoSave());
+    }
+    else
+    {
+      // Stop the auto-save timer.
+      autoSaveTimer.cancel();
+      autoSaveTimer.destroy();
+      autoSaveTimer = null;
+    }
+
+    return saveDataDirty = value;
+  }
+
+  /**
+   * A timer used to auto-save the chart after a period of inactivity.
+   */
+  var autoSaveTimer:FlxTimer;
+
+  /**
+   * Whether the difficulty tree view in the toolbox has been modified and needs to be updated.
+   * This happens when we add/remove difficulties.
+   */
+  var difficultySelectDirty:Bool = true;
+
+  /**
+   * Whether the character select view in the toolbox has been modified and needs to be updated.
+   * This happens when we add/remove characters.
+   */
+  var characterSelectDirty:Bool = true;
+
+  var isInPlaytestMode:Bool = false;
+
+  /**
+   * The list of command previously performed. Used for undoing previous actions.
+   */
+  var undoHistory:Array<ChartEditorCommand> = [];
+
+  /**
+   * The list of commands that have been undone. Used for redoing previous actions.
+   */
+  var redoHistory:Array<ChartEditorCommand> = [];
+
+  /**
+   * Variable used to track how long the user has been holding the undo keybind.
+   */
+  var undoHeldTime:Float = 0.0;
+
+  /**
+   * Variable used to track how long the user has been holding the redo keybind.
+   */
+  var redoHeldTime:Float = 0.0;
+
+  /**
+   * Whether the undo/redo histories have changed since the last time the UI was updated.
+   */
+  var commandHistoryDirty:Bool = true;
+
+  /**
+   * The notes which are currently in the user's selection.
+   */
+  var currentNoteSelection:Array<SongNoteData> = [];
+
+  /**
+   * The events which are currently in the user's selection.
+   */
+  var currentEventSelection:Array<SongEventData> = [];
+
+  /**
+   * The position where the user clicked to start a selection.
+   * The selection box extends from this point to the current mouse position.
+   */
+  var selectionBoxStartPos:FlxPoint = null;
+
+  /**
+   * Whether the user's last mouse click was on the playhead scroll area.
+   */
+  var gridPlayheadScrollAreaPressed:Bool = false;
+
+  /**
+   * The SongNoteData which is currently being placed.
+   * As the user drags, we will update this note's sustain length.
+   */
+  var currentPlaceNoteData:SongNoteData = null;
+
+  /**
+   * The Dialog components representing the currently available tool windows.
+   * Dialogs are retained here even when collapsed or hidden.
+   */
+  var activeToolboxes:Map<String, Dialog> = new Map<String, Dialog>();
+
+  /**
+   * AUDIO AND SOUND DATA
+   */
+  // ==============================
+
+  /**
+   * The audio track for the instrumental.
+   */
+  var audioInstTrack:FlxSound;
+
+  /**
+   * The audio track for the vocals.
+   */
+  var audioVocalTrackGroup:VocalGroup;
+
+  /**
+   * A map of the audio tracks for each character's vocals.
+   * - Keys are the character IDs.
+   * - Values are the FlxSound objects to play that character's vocals.
+   * 
+   * When switching characters, the elements of the VocalGroup will be swapped to match the new character.
+   */
+  var audioVocalTracks:Map<String, FlxSound> = new Map<String, FlxSound>();
+
+  /**
+   * CHART DATA
+   */
+  // ==============================
+
+  /**
+   * The song metadata.
+   * - Keys are the variation IDs. At least one (`default`) must exist.
+   * - Values are the relevant metadata, ready to be serialized to JSON.
+   */
+  var songMetadata:Map<String, SongMetadata>;
+
+  var availableVariations(get, null):Array<String>;
+
+  function get_availableVariations():Array<String>
+  {
+    return [for (x in songMetadata.keys()) x];
+  }
+
+  /**
+   * The song chart data.
+   * - Keys are the variation IDs. At least one (`default`) must exist.
+   * - Values are the relevant chart data, ready to be serialized to JSON.
+   */
+  var songChartData:Map<String, SongChartData>;
+
+  /**
+   * Convenience property to get the chart data for the current variation.
+   */
+  var currentSongMetadata(get, set):SongMetadata;
+
+  function get_currentSongMetadata():SongMetadata
+  {
+    var result = songMetadata.get(selectedVariation);
+    if (result == null)
+    {
+      result = new SongMetadata('Dad Battle', 'Kawai Sprite', selectedVariation);
+      songMetadata.set(selectedVariation, result);
+    }
+    return result;
+  }
+
+  function set_currentSongMetadata(value:SongMetadata):SongMetadata
+  {
+    songMetadata.set(selectedVariation, value);
+    return value;
+  }
+
+  /**
+   * Convenience property to get the chart data for the current variation.
+   */
+  var currentSongChartData(get, set):SongChartData;
+
+  function get_currentSongChartData():SongChartData
+  {
+    var result = songChartData.get(selectedVariation);
+    if (result == null)
+    {
+      result = new SongChartData(1.0, [], []);
+      songChartData.set(selectedVariation, result);
+    }
+    return result;
+  }
+
+  function set_currentSongChartData(value:SongChartData):SongChartData
+  {
+    songChartData.set(selectedVariation, value);
+    return value;
+  }
+
+  /**
+   * Convenience property to get (and set) the scroll speed for the current difficulty.
+   */
+  var currentSongChartScrollSpeed(get, set):Float;
+
+  function get_currentSongChartScrollSpeed():Float
+  {
+    var result = currentSongChartData.scrollSpeed.get(selectedDifficulty);
+    if (result == null)
+    {
+      // Initialize to the default value if not set.
+      currentSongChartData.scrollSpeed.set(selectedDifficulty, 1.0);
+      return 1.0;
+    }
+    return result;
+  }
+
+  function set_currentSongChartScrollSpeed(value:Float):Float
+  {
+    currentSongChartData.scrollSpeed.set(selectedDifficulty, value);
+    return value;
+  }
+
+  /**
+   * Convenience property to get the note data for the current difficulty.
+   */
+  var currentSongChartNoteData(get, set):Array<SongNoteData>;
+
+  function get_currentSongChartNoteData():Array<SongNoteData>
+  {
+    var result = currentSongChartData.notes.get(selectedDifficulty);
+    if (result == null)
+    {
+      // Initialize to the default value if not set.
+      result = [];
+      currentSongChartData.notes.set(selectedDifficulty, result);
+      return result;
+    }
+    return result;
+  }
+
+  function set_currentSongChartNoteData(value:Array<SongNoteData>):Array<SongNoteData>
+  {
+    currentSongChartData.notes.set(selectedDifficulty, value);
+    return value;
+  }
+
+  /**
+   * Convenience property to get the event data for the current difficulty.
+   */
+  var currentSongChartEventData(get, set):Array<SongEventData>;
+
+  function get_currentSongChartEventData():Array<SongEventData>
+  {
+    if (currentSongChartData.events == null)
+    {
+      // Initialize to the default value if not set.
+      currentSongChartData.events = [];
+    }
+    return currentSongChartData.events;
+  }
+
+  function set_currentSongChartEventData(value:Array<SongEventData>):Array<SongEventData>
+  {
+    currentSongChartData.events = value;
+    return value;
+  }
+
+  public var currentSongNoteSkin(get, set):String;
+
+  function get_currentSongNoteSkin():String
+  {
+    if (currentSongMetadata.playData.noteSkin == null)
+    {
+      // Initialize to the default value if not set.
+      currentSongMetadata.playData.noteSkin = 'Normal';
+    }
+    return currentSongMetadata.playData.noteSkin;
+  }
+
+  function set_currentSongNoteSkin(value:String):String
+  {
+    return currentSongMetadata.playData.noteSkin = value;
+  }
+
+  var currentSongStage(get, set):String;
+
+  function get_currentSongStage():String
+  {
+    if (currentSongMetadata.playData.stage == null)
+    {
+      // Initialize to the default value if not set.
+      currentSongMetadata.playData.stage = 'mainStage';
+    }
+    return currentSongMetadata.playData.stage;
+  }
+
+  function set_currentSongStage(value:String):String
+  {
+    return currentSongMetadata.playData.stage = value;
+  }
+
+  var currentSongName(get, set):String;
+
+  function get_currentSongName():String
+  {
+    if (currentSongMetadata.songName == null)
+    {
+      // Initialize to the default value if not set.
+      currentSongMetadata.songName = 'New Song';
+    }
+    return currentSongMetadata.songName;
+  }
+
+  function set_currentSongName(value:String):String
+  {
+    return currentSongMetadata.songName = value;
+  }
+
+  var currentSongId(get, null):String;
+
+  function get_currentSongId():String
+  {
+    return currentSongName.toLowerKebabCase();
+  }
+
+  var currentSongArtist(get, set):String;
+
+  function get_currentSongArtist():String
+  {
+    if (currentSongMetadata.artist == null)
+    {
+      // Initialize to the default value if not set.
+      currentSongMetadata.artist = 'Unknown';
+    }
+    return currentSongMetadata.artist;
+  }
+
+  function set_currentSongArtist(value:String):String
+  {
+    return currentSongMetadata.artist = value;
+  }
+
+  /**
+   * RENDER OBJECTS
+   */
+  // ==============================
+
+  /**
+   * The IMAGE used for the grid. Updated by ChartEditorThemeHandler.
+   */
+  var gridBitmap:BitmapData;
+
+  /**
+   * The IMAGE used for the selection squares. Updated by ChartEditorThemeHandler.
+   * Used two ways:
+   * 1. A sprite is given this bitmap and placed over selected notes.
+   * 2. The image is split and used for a 9-slice sprite for the selection box.
+   */
+  var selectionSquareBitmap:BitmapData = null;
+
+  /**
+   * The tiled sprite used to display the grid.
+   * The height is the length of the song, and scrolling is done by simply the sprite.
+   */
+  var gridTiledSprite:FlxSprite;
+
+  /**
+   * The playhead representing the current position in the song.
+   * Can move around on the grid independently of the view.
+   */
+  var gridPlayhead:FlxSpriteGroup;
+
+  var gridPlayheadScrollArea:FlxSprite;
+
+  /**
+   * A sprite used to indicate the note that will be placed on click.
+   */
+  var gridGhostNote:ChartEditorNoteSprite;
+
+  /**
+   * A sprite used to indicate the event that will be placed on click.
+   */
+  var gridGhostEvent:ChartEditorEventSprite;
+
+  /**
+   * The waveform which (optionally) displays over the grid, underneath the notes and playhead.
+   */
+  var gridSpectrogram:PolygonSpectogram;
+
+  /**
+   * The rectangle used for the note preview area.
+   * Should span the full height of the song. We scribble on this to draw the preview.
+   */
+  var notePreviewBitmap:BitmapData;
+
+  /**
+   * The sprite used to display the note preview area.
+   * We move this up and down to scroll the preview.
+   */
+  var notePreviewSprite:FlxSprite;
+
+  /**
+   * The rectangular sprite used for rendering the selection box.
+   * Uses a 9-slice to stretch the selection box to the correct size without warping.
+   */
+  var selectionBoxSprite:FlxSliceSprite;
+
+  /**
+   * The opponent's health icon.
+   */
+  var healthIconDad:HealthIcon;
+
+  /**
+   * The player's health icon.
+   */
+  var healthIconBF:HealthIcon;
+
+  /**
+   * The purple background sprite.
+   */
+  var menuBG:FlxSprite;
+
+  /**
+   * The sprite group containing the note graphics.
+   * Only displays a subset of the data from `currentSongChartNoteData`,
+   * and kills notes that are off-screen to be recycled later.
+   */
+  var renderedNotes:FlxTypedSpriteGroup<ChartEditorNoteSprite>;
+
+  /**
+   * The sprite group containing the song events.
+   * Only displays a subset of the data from `currentSongChartEventData`,
+   * and kills events that are off-screen to be recycled later.
+   */
+  var renderedEvents:FlxTypedSpriteGroup<ChartEditorEventSprite>;
+
+  var renderedSelectionSquares:FlxTypedSpriteGroup<FlxSprite>;
+
+  var notifBar:SideBar;
+  var playbarHead:Slider;
+
+  public function new()
+  {
+    // Load the HaxeUI XML file.
+    super(CHART_EDITOR_LAYOUT);
+  }
+
+  override function create()
+  {
+    // Get rid of any music from the previous state.
+    FlxG.sound.music.stop();
+
+    buildDefaultSongData();
+
+    buildBackground();
+
+    currentTheme = ChartEditorTheme.Light;
+
+    buildGrid();
+    buildSelectionBox();
+
+    // Add the HaxeUI components after the grid so they're on top.
+    super.create();
+    buildAdditionalUI();
+
+    // Setup the onClick listeners for the UI after it's been created.
+    setupUIListeners();
+
+    setupAutoSave();
+
+    // TODO: We should be loading the music later when the user requests it.
+    // loadDefaultMusic();
+
+    // TODO: Change to false.
+    var canCloseInitialDialog = true;
+    ChartEditorDialogHandler.openWelcomeDialog(this, canCloseInitialDialog);
+  }
+
+  function buildDefaultSongData()
+  {
+    selectedVariation = Constants.DEFAULT_VARIATION;
+    selectedDifficulty = Constants.DEFAULT_DIFFICULTY;
+
+    // Initialize the song metadata.
+    songMetadata = new Map<String, SongMetadata>();
+
+    // Initialize the song chart data.
+    songChartData = new Map<String, SongChartData>();
+
+    audioVocalTrackGroup = new VocalGroup();
+  }
+
+  /**
+   * Builds and displays the background sprite.
+   */
+  function buildBackground()
+  {
+    menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat'));
+    add(menuBG);
+
+    menuBG.setGraphicSize(Std.int(menuBG.width * 1.1));
+    menuBG.updateHitbox();
+    menuBG.screenCenter();
+    menuBG.scrollFactor.set(0, 0);
+  }
+
+  /**
+   * Builds and displays the chart editor grid, including the playhead and cursor.
+   */
+  function buildGrid()
+  {
+    gridTiledSprite = new FlxTiledSprite(gridBitmap, gridBitmap.width, 1000, false, true);
+    gridTiledSprite.x = FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; // Center the grid.
+    gridTiledSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; // Push down to account for the menu bar.
+    add(gridTiledSprite);
+
+    gridGhostNote = new ChartEditorNoteSprite(this);
+    gridGhostNote.alpha = 0.6;
+    gridGhostNote.noteData = new SongNoteData(-1, -1, 0, "");
+    gridGhostNote.visible = false;
+    add(gridGhostNote);
+
+    gridGhostEvent = new ChartEditorEventSprite(this);
+    gridGhostEvent.alpha = 0.6;
+    gridGhostEvent.eventData = new SongEventData(-1, "", {});
+    gridGhostEvent.visible = false;
+    add(gridGhostEvent);
+
+    buildNoteGroup();
+
+    gridPlayheadScrollArea = new FlxSprite(gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH,
+      MENU_BAR_HEIGHT).makeGraphic(PLAYHEAD_SCROLL_AREA_WIDTH, FlxG.height - MENU_BAR_HEIGHT, PLAYHEAD_SCROLL_AREA_COLOR);
+    add(gridPlayheadScrollArea);
+
+    // The playhead that show the current position in the song.
+    gridPlayhead = new FlxSpriteGroup();
+    add(gridPlayhead);
+
+    var playheadWidth = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH * 2);
+    var playheadBaseYPos = MENU_BAR_HEIGHT + GRID_TOP_PAD;
+    gridPlayhead.setPosition(gridTiledSprite.x, playheadBaseYPos);
+    var playheadSprite = new FlxSprite().makeGraphic(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR);
+    playheadSprite.x = -PLAYHEAD_SCROLL_AREA_WIDTH;
+    playheadSprite.y = 0;
+    gridPlayhead.add(playheadSprite);
+
+    var playheadBlock = ChartEditorThemeHandler.buildPlayheadBlock();
+    playheadBlock.x = -PLAYHEAD_SCROLL_AREA_WIDTH;
+    playheadBlock.y = -PLAYHEAD_HEIGHT / 2;
+    gridPlayhead.add(playheadBlock);
+
+    // Character icons.
+    healthIconDad = new HealthIcon('dad');
+    healthIconDad.autoUpdate = false;
+    healthIconDad.size.set(0.5, 0.5);
+    healthIconDad.x = gridTiledSprite.x - 15 - (HealthIcon.HEALTH_ICON_SIZE * 0.5);
+    healthIconDad.y = gridTiledSprite.y + 5;
+    add(healthIconDad);
+
+    healthIconBF = new HealthIcon('bf');
+    healthIconBF.autoUpdate = false;
+    healthIconBF.size.set(0.5, 0.5);
+    healthIconBF.x = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15;
+    healthIconBF.y = gridTiledSprite.y + 5;
+    healthIconBF.flipX = true;
+    add(healthIconBF);
+  }
+
+  function buildSelectionBox()
+  {
+    selectionBoxSprite.scrollFactor.set(0, 0);
+    add(selectionBoxSprite);
+
+    setSelectionBoxBounds();
+  }
+
+  function setSelectionBoxBounds(?bounds:FlxRect = null)
+  {
+    if (bounds == null)
+    {
+      selectionBoxSprite.visible = false;
+      selectionBoxSprite.x = -9999;
+      selectionBoxSprite.y = -9999;
+    }
+    else
+    {
+      selectionBoxSprite.visible = true;
+      selectionBoxSprite.x = bounds.x;
+      selectionBoxSprite.y = bounds.y;
+      selectionBoxSprite.width = bounds.width;
+      selectionBoxSprite.height = bounds.height;
+    }
+  }
+
+  function buildSpectrogram(target:FlxSound)
+  {
+    gridSpectrogram = new PolygonSpectogram(target, SPECTROGRAM_COLOR, FlxG.height / 2, Math.floor(FlxG.height / 2));
+    // Halfway through the grid.
+    // gridSpectrogram.x = gridTiledSprite.x + STRUMLINE_SIZE * GRID_SIZE;
+    // gridSpectrogram.y = gridTiledSprite.y;
+    gridSpectrogram.x = 200;
+    gridSpectrogram.y = 200;
+    gridSpectrogram.visType = STATIC; // We move the spectrogram manually.
+    gridSpectrogram.waveAmplitude = 50;
+    gridSpectrogram.scrollFactor.set(0, 0);
+    add(gridSpectrogram);
+  }
+
+  /**
+   * Builds the group that will hold all the notes.
+   */
+  function buildNoteGroup()
+  {
+    renderedNotes = new FlxTypedSpriteGroup<ChartEditorNoteSprite>();
+    renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y);
+    add(renderedNotes);
+
+    renderedEvents = new FlxTypedSpriteGroup<ChartEditorEventSprite>();
+    renderedEvents.setPosition(gridTiledSprite.x, gridTiledSprite.y);
+    add(renderedEvents);
+
+    renderedSelectionSquares = new FlxTypedSpriteGroup<FlxSprite>();
+    renderedSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y);
+    add(renderedSelectionSquares);
+  }
+
+  var playbarHeadLayout:Component;
+
+  function buildAdditionalUI():Void
+  {
+    notifBar = cast buildComponent(CHART_EDITOR_NOTIFBAR_LAYOUT);
+    add(notifBar);
+
+    playbarHeadLayout = buildComponent(CHART_EDITOR_PLAYBARHEAD_LAYOUT);
+
+    playbarHeadLayout.width = FlxG.width - 8;
+    playbarHeadLayout.height = 10;
+    playbarHeadLayout.x = 4;
+    playbarHeadLayout.y = FlxG.height - 48 - 8;
+
+    playbarHead = playbarHeadLayout.findComponent('playbarHead', Slider);
+    playbarHead.allowFocus = false;
+    playbarHead.width = FlxG.width;
+    playbarHead.height = 10;
+    playbarHead.styleString = "padding-left: 0px; padding-right: 0px; border-left: 0px; border-right: 0px;";
+
+    playbarHead.onDragStart = function(_:DragEvent)
+    {
+      playbarHeadDragging = true;
+
+      // If we were dragging the playhead while the song was playing, resume playing.
+      if (audioInstTrack != null && audioInstTrack.playing)
+      {
+        playbarHeadDraggingWasPlaying = true;
+        stopAudioPlayback();
+      }
+      else
+      {
+        playbarHeadDraggingWasPlaying = false;
+      }
+    }
+
+    playbarHead.onDragEnd = function(_:DragEvent)
+    {
+      playbarHeadDragging = false;
+
+      // Set the song position to where the playhead was moved to.
+      scrollPositionInPixels = songLengthInPixels * (playbarHead.value / 100);
+      // Update the conductor and audio tracks to match.
+      moveSongToScrollPosition();
+
+      // If we were dragging the playhead while the song was playing, resume playing.
+      if (playbarHeadDraggingWasPlaying)
+      {
+        playbarHeadDraggingWasPlaying = false;
+        startAudioPlayback();
+      }
+    }
+
+    // add(playbarHeadLayout);
+  }
+
+  /**
+   * Sets up the onClick listeners for the UI.
+   */
+  function setupUIListeners():Void
+  {
+    // Add functionality to the playbar.
+
+    addUIClickListener('playbarPlay', (event:MouseEvent) -> toggleAudioPlayback());
+    addUIClickListener('playbarStart', (event:MouseEvent) -> playbarButtonPressed = 'playbarStart');
+    addUIClickListener('playbarBack', (event:MouseEvent) -> playbarButtonPressed = 'playbarBack');
+    addUIClickListener('playbarForward', (event:MouseEvent) -> playbarButtonPressed = 'playbarForward');
+    addUIClickListener('playbarEnd', (event:MouseEvent) -> playbarButtonPressed = 'playbarEnd');
+
+    // Add functionality to the menu items.
+
+    addUIClickListener('menubarItemNewChart', (event:MouseEvent) -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
+    addUIClickListener('menubarItemSaveChartAs', (event:MouseEvent) -> exportAllSongData());
+    addUIClickListener('menubarItemLoadInst', (event:MouseEvent) -> ChartEditorDialogHandler.openUploadInstDialog(this, true));
+
+    addUIClickListener('menubarItemUndo', (event:MouseEvent) -> undoLastCommand());
+
+    addUIClickListener('menubarItemRedo', (event:MouseEvent) -> redoLastCommand());
+
+    addUIClickListener('menubarItemCopy', (event:MouseEvent) ->
+    {
+      // Doesn't use a command because it's not undoable.
+      SongDataUtils.writeItemsToClipboard({
+        notes: SongDataUtils.buildNoteClipboard(currentNoteSelection),
+        events: SongDataUtils.buildEventClipboard(currentEventSelection),
+      });
+    });
+
+    addUIClickListener('menubarItemCut', (event:MouseEvent) ->
+    {
+      performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection));
+    });
+
+    addUIClickListener('menubarItemPaste', (event:MouseEvent) ->
+    {
+      performCommand(new PasteItemsCommand(scrollPositionInMs + playheadPositionInMs));
+    });
+
+    addUIClickListener('menubarItemDelete', (event:MouseEvent) ->
+    {
+      if (currentNoteSelection.length > 0 && currentEventSelection.length > 0)
+      {
+        performCommand(new RemoveItemsCommand(currentNoteSelection, currentEventSelection));
+      }
+      else if (currentNoteSelection.length > 0)
+      {
+        performCommand(new RemoveNotesCommand(currentNoteSelection));
+      }
+      else if (currentEventSelection.length > 0)
+      {
+        performCommand(new RemoveEventsCommand(currentEventSelection));
+      }
+      else
+      {
+        // Do nothing???
+      }
+    });
+
+    addUIClickListener('menubarItemSelectAll', (event:MouseEvent) ->
+    {
+      performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection));
+    });
+
+    addUIClickListener('menubarItemSelectInverse', (event:MouseEvent) ->
+    {
+      performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection));
+    });
+
+    addUIClickListener('menubarItemSelectNone', (event:MouseEvent) ->
+    {
+      performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
+    });
+
+    addUIClickListener('menubarItemSelectRegion', (event:MouseEvent) -> {
+      // TODO: Implement this.
+    });
+
+    addUIClickListener('menubarItemSelectBeforeCursor', (event:MouseEvent) -> {
+      // TODO: Implement this.
+    });
+
+    addUIClickListener('menubarItemSelectAfterCursor', (event:MouseEvent) -> {
+      // TODO: Implement this.
+    });
+
+    addUIClickListener('menubarItemAbout', (event:MouseEvent) -> ChartEditorDialogHandler.openAboutDialog(this));
+
+    addUIClickListener('menubarItemUserGuide', (event:MouseEvent) -> ChartEditorDialogHandler.openUserGuideDialog(this));
+
+    addUIChangeListener('menubarItemDownscroll', (event:UIEvent) ->
+    {
+      isViewDownscroll = event.value;
+    });
+    setUICheckboxSelected('menubarItemDownscroll', isViewDownscroll);
+
+    addUIChangeListener('menuBarItemThemeLight', (event:UIEvent) ->
+    {
+      if (event.target.value)
+        currentTheme = ChartEditorTheme.Light;
+    });
+    setUICheckboxSelected('menuBarItemThemeLight', currentTheme == ChartEditorTheme.Light);
+
+    addUIChangeListener('menuBarItemThemeDark', (event:UIEvent) ->
+    {
+      if (event.target.value)
+        currentTheme = ChartEditorTheme.Dark;
+    });
+    setUICheckboxSelected('menuBarItemThemeDark', currentTheme == ChartEditorTheme.Dark);
+
+    addUIChangeListener('menubarItemMetronomeEnabled', (event:UIEvent) ->
+    {
+      shouldPlayMetronome = event.value;
+    });
+    setUICheckboxSelected('menubarItemMetronomeEnabled', shouldPlayMetronome);
+
+    addUIChangeListener('menubarItemPlayerHitsounds', (event:UIEvent) ->
+    {
+      hitsoundsEnabledPlayer = event.value;
+    });
+    setUICheckboxSelected('menubarItemPlayerHitsounds', hitsoundsEnabledPlayer);
+
+    addUIChangeListener('menubarItemOpponentHitsounds', (event:UIEvent) ->
+    {
+      hitsoundsEnabledOpponent = event.value;
+    });
+    setUICheckboxSelected('menubarItemOpponentHitsounds', hitsoundsEnabledOpponent);
+
+    var instVolumeLabel:Label = findComponent('menubarLabelVolumeInstrumental', Label);
+    addUIChangeListener('menubarItemVolumeInstrumental', (event:UIEvent) ->
+    {
+      var volume:Float = event.value / 100.0;
+      if (audioInstTrack != null)
+        audioInstTrack.volume = volume;
+      instVolumeLabel.text = 'Instrumental - ${Std.int(event.value)}%';
+    });
+
+    var vocalsVolumeLabel:Label = findComponent('menubarLabelVolumeVocals', Label);
+    addUIChangeListener('menubarItemVolumeVocals', (event:UIEvent) ->
+    {
+      var volume:Float = event.value / 100.0;
+      if (audioVocalTrackGroup != null)
+        audioVocalTrackGroup.volume = volume;
+      vocalsVolumeLabel.text = 'Vocals - ${Std.int(event.value)}%';
+    });
+
+    var playbackSpeedLabel:Label = findComponent('menubarLabelPlaybackSpeed', Label);
+    addUIChangeListener('menubarItemPlaybackSpeed', (event:UIEvent) ->
+    {
+      var pitch = event.value * 2.0 / 100.0;
+      #if FLX_PITCH
+      if (audioInstTrack != null)
+        audioInstTrack.pitch = pitch;
+      if (audioVocalTrackGroup != null)
+        audioVocalTrackGroup.pitch = pitch;
+      #end
+      playbackSpeedLabel.text = 'Playback Speed - ${Std.int(event.value * 100) / 100}x';
+    });
+
+    addUIChangeListener('menubarItemToggleToolboxTools', (event:UIEvent) ->
+    {
+      ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT, event.value);
+    });
+    // setUICheckboxSelected('menubarItemToggleToolboxTools', true);
+    addUIChangeListener('menubarItemToggleToolboxNotes', (event:UIEvent) ->
+    {
+      ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value);
+    });
+    addUIChangeListener('menubarItemToggleToolboxEvents', (event:UIEvent) ->
+    {
+      ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value);
+    });
+    addUIChangeListener('menubarItemToggleToolboxDifficulty', (event:UIEvent) ->
+    {
+      ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value);
+    });
+    addUIChangeListener('menubarItemToggleToolboxMetadata', (event:UIEvent) ->
+    {
+      ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value);
+    });
+    addUIChangeListener('menubarItemToggleToolboxCharacters', (event:UIEvent) ->
+    {
+      ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT, event.value);
+    });
+    addUIChangeListener('menubarItemToggleToolboxPlayerPreview', (event:UIEvent) ->
+    {
+      ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value);
+    });
+    addUIChangeListener('menubarItemToggleToolboxOpponentPreview', (event:UIEvent) ->
+    {
+      ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value);
+    });
+
+    // TODO: Pass specific HaxeUI components to add context menus to them.
+    registerContextMenu(null, Paths.ui('chart-editor/context/test'));
+  }
+
+  /**
+   * Setup timers and listerners to handle auto-save.
+   */
+  function setupAutoSave()
+  {
+    WindowUtil.windowExit.add(onWindowClose);
+    saveDataDirty = false;
+  }
+
+  /**
+   * Called after 5 minutes without saving.
+   */
+  function autoSave()
+  {
+    saveDataDirty = false;
+
+    // Auto-save the chart.
+
+    #if html5
+    // Auto-save to local storage.
+    #else
+    // Auto-save to temp file.
+    exportAllSongData(true, true);
+    #end
+  }
+
+  function onWindowClose(exitCode:Int)
+  {
+    trace('Window exited with exit code: $exitCode');
+    trace('Should save chart? $saveDataDirty');
+
+    if (saveDataDirty)
+    {
+      exportAllSongData(true);
+    }
+  }
+
+  function cleanupAutoSave()
+  {
+    WindowUtil.windowExit.remove(onWindowClose);
+  }
+
+  public override function update(elapsed:Float)
+  {
+    // dispatchEvent gets called here.
+    super.update(elapsed);
+
+    FlxG.mouse.visible = true;
+
+    // These ones happen even if the modal dialog is open.
+    handleMusicPlayback();
+    handleNoteDisplay();
+
+    // These ones only happen if the modal dialog is not open.
+    handleScrollKeybinds();
+    // handleZoom();
+    // handleSnap();
+    handleCursor();
+
+    handleMenubar();
+    handleToolboxes();
+    handlePlaybar();
+    handlePlayhead();
+
+    handleFileKeybinds();
+    handleEditKeybinds();
+    handleViewKeybinds();
+    handleHelpKeybinds();
+
+    // DEBUG
+    #if debug
+    if (FlxG.keys.justPressed.F)
+    {
+      // This breaks the layout don't use it.
+      // showNotification('Hi there :)');
+
+      // autoSave();
+    }
+
+    if (FlxG.keys.justPressed.E)
+    {
+      currentSongMetadata.timeChanges[0].timeSignatureNum = (currentSongMetadata.timeChanges[0].timeSignatureNum == 4 ? 3 : 4);
+    }
+    #end
+
+    // Right align the BF health icon.
+
+    // Base X position to the right of the grid.
+    var baseHealthIconXPos = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15;
+    // Will be 0 when not bopping. When bopping, will increase to push the icon left.
+    var healthIconOffset = healthIconBF.width - (HealthIcon.HEALTH_ICON_SIZE * 0.5);
+    healthIconBF.x = baseHealthIconXPos - healthIconOffset;
+  }
+
+  /**
+   * Beat hit while the song is playing.
+   */
+  override function beatHit():Bool
+  {
+    // dispatchEvent gets called here.
+    if (!super.beatHit())
+      return false;
+
+    if (shouldPlayMetronome && audioInstTrack.playing)
+    {
+      playMetronomeTick(Conductor.currentBeat % 4 == 0);
+    }
+
+    return true;
+  }
+
+  /**
+   * Step hit while the song is playing.
+   */
+  override function stepHit():Bool
+  {
+    // dispatchEvent gets called here.
+    if (!super.stepHit())
+      return false;
+
+    if (audioInstTrack.playing)
+    {
+      healthIconDad.onStepHit(Conductor.currentStep);
+      healthIconBF.onStepHit(Conductor.currentStep);
+    }
+
+    // if (shouldPlayMetronome)
+    // 	playMetronomeTick(false);
+
+    return true;
+  }
+
+  /**
+   * Handle keybinds for scrolling the chart editor grid.
+  **/
+  function handleScrollKeybinds()
+  {
+    // Don't scroll when the cursor is over the UI.
+    if (isCursorOverHaxeUI)
+      return;
+
+    // Amount to scroll the grid.
+    var scrollAmount:Float = 0;
+    // Amount to scroll the playhead relative to the grid.
+    var playheadAmount:Float = 0;
+
+    // Up Arrow = Scroll Up
+    if (FlxG.keys.justPressed.UP)
+    {
+      scrollAmount = -GRID_SIZE * 0.25;
+    }
+    // Down Arrow = Scroll Down
+    if (FlxG.keys.justPressed.DOWN)
+    {
+      scrollAmount = GRID_SIZE * 0.25;
+    }
+
+    // PAGE UP = Jump Up 1 Measure
+    if (FlxG.keys.justPressed.PAGEUP)
+    {
+      scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure;
+    }
+    if (playbarButtonPressed == 'playbarBack')
+    {
+      playbarButtonPressed = '';
+      scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure;
+    }
+
+    // PAGE DOWN = Jump Down 1 Measure
+    if (FlxG.keys.justPressed.PAGEDOWN)
+    {
+      scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure;
+    }
+    if (playbarButtonPressed == 'playbarForward')
+    {
+      playbarButtonPressed = '';
+      scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure;
+    }
+
+    // Mouse Wheel = Scroll
+    if (FlxG.mouse.wheel != 0 && !FlxG.keys.pressed.CONTROL)
+    {
+      scrollAmount = -10 * FlxG.mouse.wheel;
+    }
+
+    // Middle Mouse + Drag = Scroll but move the playhead the same amount.
+    if (FlxG.mouse.pressedMiddle)
+    {
+      if (FlxG.mouse.deltaY != 0)
+      {
+        // Scroll down by the amount dragged.
+        scrollAmount += -FlxG.mouse.deltaY;
+        // Move the playhead by the same amount in the other direction so it is stationary.
+        playheadAmount += FlxG.mouse.deltaY;
+      }
+    }
+
+    // SHIFT + Scroll = Scroll Fast
+    if (FlxG.keys.pressed.SHIFT)
+    {
+      scrollAmount *= 5;
+    }
+    // CONTROL + Scroll = Scroll Precise
+    if (FlxG.keys.pressed.CONTROL)
+    {
+      scrollAmount /= 10;
+    }
+
+    // ALT = Move playhead instead.
+    if (FlxG.keys.pressed.ALT)
+    {
+      playheadAmount = scrollAmount;
+      scrollAmount = 0;
+    }
+
+    // HOME = Scroll to Top
+    if (FlxG.keys.justPressed.HOME)
+    {
+      // Scroll amount is the difference between the current position and the top.
+      scrollAmount = 0 - this.scrollPositionInPixels;
+      playheadAmount = 0 - this.playheadPositionInPixels;
+    }
+    if (playbarButtonPressed == 'playbarStart')
+    {
+      playbarButtonPressed = '';
+      scrollAmount = 0 - this.scrollPositionInPixels;
+      playheadAmount = 0 - this.playheadPositionInPixels;
+    }
+
+    // END = Scroll to Bottom
+    if (FlxG.keys.justPressed.END)
+    {
+      // Scroll amount is the difference between the current position and the bottom.
+      scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels;
+    }
+    if (playbarButtonPressed == 'playbarEnd')
+    {
+      playbarButtonPressed = '';
+      scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels;
+    }
+
+    // Apply the scroll amount.
+    this.scrollPositionInPixels += scrollAmount;
+    this.playheadPositionInPixels += playheadAmount;
+
+    // Resync the conductor and audio tracks.
+    if (scrollAmount != 0 || playheadAmount != 0)
+      moveSongToScrollPosition();
+  }
+
+  function handleZoom()
+  {
+    if (FlxG.keys.justPressed.MINUS)
+    {
+      currentZoomLevel /= 2;
+
+      // Update the grid.
+      ChartEditorThemeHandler.updateTheme(this);
+      // Update the note positions.
+      noteDisplayDirty = true;
+    }
+
+    if (FlxG.keys.justPressed.PLUS)
+    {
+      currentZoomLevel *= 2;
+
+      // Update the grid.
+      ChartEditorThemeHandler.updateTheme(this);
+      // Update the note positions.
+      noteDisplayDirty = true;
+    }
+  }
+
+  function handleSnap()
+  {
+    if (FlxG.keys.justPressed.LEFT)
+    {
+      noteSnapQuantIndex--;
+    }
+
+    if (FlxG.keys.justPressed.RIGHT)
+    {
+      noteSnapQuantIndex++;
+    }
+  }
+
+  /**
+   * Handle display of the mouse cursor.
+   */
+  function handleCursor()
+  {
+    // Note: If a menu is open in HaxeUI, don't handle cursor behavior.
+    var shouldHandleCursor = !isCursorOverHaxeUI || (selectionBoxStartPos != null);
+    var eventColumn = (STRUMLINE_SIZE * 2 + 1) - 1;
+
+    if (shouldHandleCursor)
+    {
+      var overlapsGrid:Bool = FlxG.mouse.overlaps(gridTiledSprite);
+
+      // Cursor position relative to the grid.
+      var cursorX:Float = FlxG.mouse.screenX - gridTiledSprite.x;
+      var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y;
+
+      var overlapsSelectionBorder = overlapsGrid
+        && (cursorX % 40) < (GRID_SELECTION_BORDER_WIDTH / 2)
+          || (cursorX % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2))
+            || (cursorY % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorY % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2));
+
+      if (FlxG.mouse.justPressed)
+      {
+        if (FlxG.mouse.overlaps(gridPlayheadScrollArea))
+        {
+          gridPlayheadScrollAreaPressed = true;
+        }
+        else if (!overlapsGrid || overlapsSelectionBorder)
+        {
+          selectionBoxStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
+        }
+      }
+
+      if (gridPlayheadScrollAreaPressed)
+      {
+        Cursor.cursorMode = Grabbing;
+      }
+      else if (FlxG.mouse.overlaps(gridPlayheadScrollArea))
+      {
+        Cursor.cursorMode = Pointer;
+      }
+      else
+      {
+        Cursor.cursorMode = Default;
+      }
+
+      if (gridPlayheadScrollAreaPressed && FlxG.mouse.released)
+      {
+        gridPlayheadScrollAreaPressed = false;
+      }
+
+      if (gridPlayheadScrollAreaPressed)
+      {
+        // Clicked on the playhead scroll area.
+        // Move the playhead to the cursor position.
+        this.playheadPositionInPixels = FlxG.mouse.screenY - MENU_BAR_HEIGHT - GRID_TOP_PAD;
+        moveSongToScrollPosition();
+      }
+
+      // Cursor position snapped to the grid.
+
+      // The song position of the cursor, in steps.
+      var cursorFractionalStep:Float = cursorY / GRID_SIZE / (16 / noteSnapQuant);
+      var cursorStep:Int = Std.int(Math.floor(cursorFractionalStep));
+      var cursorMs:Float = cursorStep * Conductor.stepCrochet * (16 / noteSnapQuant);
+      // The direction value for the column at the cursor.
+      var cursorColumn:Int = Math.floor(cursorX / GRID_SIZE);
+      if (cursorColumn < 0)
+        cursorColumn = 0;
+      if (cursorColumn >= (STRUMLINE_SIZE * 2 + 1 - 1))
+      {
+        // Don't invert the event column.
+        cursorColumn = (STRUMLINE_SIZE * 2 + 1 - 1);
+      }
+      else
+      {
+        // Invert player and opponent columns.
+        if (cursorColumn >= STRUMLINE_SIZE)
+        {
+          cursorColumn -= STRUMLINE_SIZE;
+        }
+        else
+        {
+          cursorColumn += STRUMLINE_SIZE;
+        }
+      }
+
+      if (selectionBoxStartPos != null)
+      {
+        var cursorXStart:Float = selectionBoxStartPos.x - gridTiledSprite.x;
+        var cursorYStart:Float = selectionBoxStartPos.y - gridTiledSprite.y;
+
+        var hasDraggedMouse:Bool = Math.abs(cursorX - cursorXStart) > DRAG_THRESHOLD || Math.abs(cursorY - cursorYStart) > DRAG_THRESHOLD;
+
+        // Determine if we dragged the mouse at all.
+        if (hasDraggedMouse)
+        {
+          // Handle releasing the selection box.
+          if (FlxG.mouse.justReleased)
+          {
+            // We released the mouse. Select the notes in the box.
+            var cursorFractionalStepStart:Float = cursorYStart / GRID_SIZE;
+            var cursorStepStart:Int = Math.floor(cursorFractionalStepStart);
+            var cursorMsStart:Float = cursorStepStart * Conductor.stepCrochet;
+            var cursorColumnBase:Int = Math.floor(cursorX / GRID_SIZE);
+            var cursorColumnBaseStart:Int = Math.floor(cursorXStart / GRID_SIZE);
+
+            // Since this selects based on noteData directly,
+            // we don't need to specifically exclude sustain pieces.
+
+            // This logic is gross because the columns go 4567-0123-8.
+            // We build a list of columns to select.
+            var columnStart:Int = Std.int(Math.min(cursorColumnBase, cursorColumnBaseStart));
+            var columnEnd:Int = Std.int(Math.max(cursorColumnBase, cursorColumnBaseStart));
+            var columns:Array<Int> = [for (i in columnStart...(columnEnd + 1)) i].map(function(i:Int):Int
+            {
+              if (i >= eventColumn)
+              {
+                // Don't invert the event column.
+                return eventColumn;
+              }
+              else if (i >= STRUMLINE_SIZE)
+              {
+                // Invert the player columns.
+                return i - STRUMLINE_SIZE;
+              }
+              else if (i >= 0)
+              {
+                // Invert the opponent columns.
+                return i + STRUMLINE_SIZE;
+              }
+              else
+              {
+                // Minimum of 0.
+                return 0;
+              }
+            });
+
+            if (columns.length > 0)
+            {
+              var notesToSelect:Array<SongNoteData> = currentSongChartNoteData;
+              notesToSelect = SongDataUtils.getNotesInTimeRange(notesToSelect, Math.min(cursorMsStart, cursorMs), Math.max(cursorMsStart, cursorMs));
+              notesToSelect = SongDataUtils.getNotesWithData(notesToSelect, columns);
+
+              var eventsToSelect:Array<SongEventData> = [];
+
+              if (columns.indexOf(eventColumn) != -1)
+              {
+                // The drag selection included the event column.
+                eventsToSelect = currentSongChartEventData;
+                eventsToSelect = SongDataUtils.getEventsInTimeRange(eventsToSelect, Math.min(cursorMsStart, cursorMs), Math.max(cursorMsStart, cursorMs));
+              }
+
+              if (notesToSelect.length > 0 || eventsToSelect.length > 0)
+              {
+                if (FlxG.keys.pressed.CONTROL)
+                {
+                  // Add to the selection.
+                  performCommand(new SelectItemsCommand(notesToSelect, eventsToSelect));
+                }
+                else
+                {
+                  // Set the selection.
+                  performCommand(new SetItemSelectionCommand(notesToSelect, eventsToSelect, currentNoteSelection, currentEventSelection));
+                }
+              }
+              else
+              {
+                // We made a selection box, but it didn't select anything.
+              }
+            }
+            else
+            {
+              // We made a selection box, but it didn't select any columns.
+            }
+
+            // Clear the selection box.
+            selectionBoxStartPos = null;
+            setSelectionBoxBounds();
+          }
+          else
+          {
+            // Render the selection box.
+            var selectionRect = new FlxRect();
+            selectionRect.x = Math.min(FlxG.mouse.screenX, selectionBoxStartPos.x);
+            selectionRect.y = Math.min(FlxG.mouse.screenY, selectionBoxStartPos.y);
+            selectionRect.width = Math.abs(FlxG.mouse.screenX - selectionBoxStartPos.x);
+            selectionRect.height = Math.abs(FlxG.mouse.screenY - selectionBoxStartPos.y);
+            setSelectionBoxBounds(selectionRect);
+          }
+        }
+        else if (FlxG.mouse.justReleased)
+        {
+          // Clear the selection box.
+          selectionBoxStartPos = null;
+          setSelectionBoxBounds();
+
+          if (overlapsGrid)
+          {
+            // We clicked on the grid without moving the mouse.
+
+            // Find the first note that is at the cursor position.
+            var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool
+            {
+              // If note.alive is false, the note is dead and awaiting recycling.
+              return note.alive && FlxG.mouse.overlaps(note);
+            });
+            var highlightedEvent:ChartEditorEventSprite = null;
+            if (highlightedNote == null)
+            {
+              highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool
+              {
+                return event.alive && FlxG.mouse.overlaps(event);
+              });
+            }
+
+            if (FlxG.keys.pressed.CONTROL)
+            {
+              if (highlightedNote != null)
+              {
+                // Handle the case of clicking on a sustain piece.
+                highlightedNote = highlightedNote.getBaseNoteSprite();
+                // Control click to select/deselect an individual note.
+                if (isNoteSelected(highlightedNote.noteData))
+                {
+                  performCommand(new DeselectItemsCommand([highlightedNote.noteData], []));
+                }
+                else
+                {
+                  performCommand(new SelectItemsCommand([highlightedNote.noteData], []));
+                }
+              }
+              else if (highlightedEvent != null)
+              {
+                // Control click to select/deselect an individual note.
+                if (isEventSelected(highlightedEvent.eventData))
+                {
+                  performCommand(new DeselectItemsCommand([], [highlightedEvent.eventData]));
+                }
+                else
+                {
+                  performCommand(new SelectItemsCommand([], [highlightedEvent.eventData]));
+                }
+              }
+              else
+              {
+                // Do nothing if you control-clicked on an empty space.
+              }
+            }
+            else
+            {
+              if (highlightedNote != null)
+              {
+                // Click a note to select it.
+                performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection));
+              }
+              else if (highlightedEvent != null)
+              {
+                // Click an event to select it.
+                performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection));
+              }
+              else
+              {
+                // Click on an empty space to deselect everything.
+                performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
+              }
+            }
+          }
+          else
+          {
+            // If we clicked and released outside the grid, do nothing.
+          }
+        }
+      }
+      else if (currentPlaceNoteData != null)
+      {
+        // Handle extending the note as you drag.
+
+        // Since use Math.floor and stepCrochet here, the hold notes will be beat snapped.
+        var dragLengthSteps:Float = Math.floor((cursorMs - currentPlaceNoteData.time) / Conductor.stepCrochet);
+
+        // Without this, the newly placed note feels too short compared to the user's input.
+        var INCREMENT:Float = 1.0;
+        var dragLengthMs:Float = (dragLengthSteps + INCREMENT) * Conductor.stepCrochet;
+
+        // TODO: Add and update some sort of preview?
+
+        if (FlxG.mouse.justReleased)
+        {
+          if (dragLengthSteps > 0)
+          {
+            // Apply the new length.
+            performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, dragLengthMs));
+          }
+
+          // Finished dragging. Release the note.
+          currentPlaceNoteData = null;
+        }
+      }
+      else
+      {
+        if (FlxG.mouse.justPressed)
+        {
+          // Just clicked to place a note.
+          if (overlapsGrid && !overlapsSelectionBorder)
+          {
+            // We clicked on the grid without moving the mouse.
+
+            // Find the first note that is at the cursor position.
+            var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool
+            {
+              // If note.alive is false, the note is dead and awaiting recycling.
+              return note.alive && FlxG.mouse.overlaps(note);
+            });
+            var highlightedEvent:ChartEditorEventSprite = null;
+            if (highlightedNote == null)
+            {
+              highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool
+              {
+                // If event.alive is false, the event is dead and awaiting recycling.
+                return event.alive && FlxG.mouse.overlaps(event);
+              });
+            }
+
+            if (FlxG.keys.pressed.CONTROL)
+            {
+              // Control click to select/deselect an individual note.
+              if (highlightedNote != null)
+              {
+                if (isNoteSelected(highlightedNote.noteData))
+                {
+                  performCommand(new DeselectItemsCommand([highlightedNote.noteData], []));
+                }
+                else
+                {
+                  performCommand(new SelectItemsCommand([highlightedNote.noteData], []));
+                }
+              }
+              else if (highlightedEvent != null)
+              {
+                if (isEventSelected(highlightedEvent.eventData))
+                {
+                  performCommand(new DeselectItemsCommand([], [highlightedEvent.eventData]));
+                }
+                else
+                {
+                  performCommand(new SelectItemsCommand([], [highlightedEvent.eventData]));
+                }
+              }
+              else
+              {
+                // Do nothing when control clicking nothing.
+              }
+            }
+            else
+            {
+              if (highlightedNote != null)
+              {
+                // Click a note to select it.
+                performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection));
+              }
+              else if (highlightedEvent != null)
+              {
+                // Click an event to select it.
+                performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection));
+              }
+              else
+              {
+                // Click a blank space to place a note and select it.
+
+                if (cursorColumn == eventColumn)
+                {
+                  // Create an event and place it in the chart.
+                  // TODO: Figure out configuring event data.
+                  var newEventData:SongEventData = new SongEventData(cursorMs, selectedEventKind, selectedEventData);
+
+                  performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL));
+                }
+                else
+                {
+                  // Create a note and place it in the chart.
+                  var newNoteData:SongNoteData = new SongNoteData(cursorMs, cursorColumn, 0, selectedNoteKind);
+
+                  performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
+
+                  currentPlaceNoteData = newNoteData;
+                }
+              }
+            }
+          }
+          else
+          {
+            // If we clicked and released outside the grid, do nothing.
+          }
+        }
+
+        var rightMouseUpdated:Bool = (FlxG.mouse.justPressedRight)
+          || (FlxG.mouse.pressedRight && (FlxG.mouse.deltaX > 0 || FlxG.mouse.deltaY > 0));
+        if (rightMouseUpdated && overlapsGrid)
+        {
+          // We right clicked on the grid.
+
+          // Find the first note that is at the cursor position.
+          var highlightedNote:ChartEditorNoteSprite = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool
+          {
+            // If note.alive is false, the note is dead and awaiting recycling.
+            return note.alive && FlxG.mouse.overlaps(note);
+          });
+          var highlightedEvent:ChartEditorEventSprite = null;
+          if (highlightedNote == null)
+          {
+            highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool
+            {
+              // If event.alive is false, the event is dead and awaiting recycling.
+              return event.alive && FlxG.mouse.overlaps(event);
+            });
+          }
+
+          if (highlightedNote != null)
+          {
+            // Handle the case of clicking on a sustain piece.
+            highlightedNote = highlightedNote.getBaseNoteSprite();
+            // Remove the note.
+            performCommand(new RemoveNotesCommand([highlightedNote.noteData]));
+          }
+          else if (highlightedEvent != null)
+          {
+            // Remove the event.
+            performCommand(new RemoveEventsCommand([highlightedEvent.eventData]));
+          }
+          else
+          {
+            // Right clicked on nothing.
+          }
+        }
+
+        // Handle grid cursor.
+        if (overlapsGrid && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed)
+        {
+          Cursor.cursorMode = Pointer;
+
+          // Indicate that we can place a note here.
+
+          if (cursorColumn == eventColumn)
+          {
+            gridGhostEvent.visible = true;
+            gridGhostNote.visible = false;
+
+            if (selectedEventKind != gridGhostEvent.eventData.event)
+            {
+              gridGhostEvent.eventData.event = selectedEventKind;
+            }
+
+            gridGhostEvent.eventData.time = cursorMs;
+            gridGhostEvent.updateEventPosition(renderedEvents);
+          }
+          else
+          {
+            gridGhostEvent.visible = false;
+            gridGhostNote.visible = true;
+
+            if (cursorColumn != gridGhostNote.noteData.data || selectedNoteKind != gridGhostNote.noteData.kind)
+            {
+              gridGhostNote.noteData.kind = selectedNoteKind;
+              gridGhostNote.noteData.data = cursorColumn;
+              gridGhostNote.playNoteAnimation();
+            }
+
+            gridGhostNote.noteData.time = cursorMs;
+            gridGhostNote.updateNotePosition(renderedNotes);
+          }
+
+          // gridCursor.visible = true;
+          // // X and Y are the cursor position relative to the grid, snapped to the top left of the grid square.
+          // gridCursor.x = Math.floor(cursorX / GRID_SIZE) * GRID_SIZE + gridTiledSprite.x + (GRID_SELECTION_BORDER_WIDTH / 2);
+          // gridCursor.y = cursorStep * GRID_SIZE + gridTiledSprite.y + (GRID_SELECTION_BORDER_WIDTH / 2);
+        }
+        else
+        {
+          gridGhostNote.visible = false;
+          gridGhostEvent.visible = false;
+          Cursor.cursorMode = Default;
+        }
+      }
+    }
+    else
+    {
+      gridGhostNote.visible = false;
+      gridGhostEvent.visible = false;
+    }
+
+    if (isCursorOverHaxeUIButton && Cursor.cursorMode == Default)
+    {
+      Cursor.cursorMode = Pointer;
+    }
+  }
+
+  /**
+   * Handle using `renderedNotes` to display notes from `currentSongChartNoteData`.
+   */
+  function handleNoteDisplay()
+  {
+    if (noteDisplayDirty)
+    {
+      noteDisplayDirty = false;
+
+      // Update for whether downscroll is enabled.
+      renderedNotes.flipX = (isViewDownscroll);
+
+      // Calculate the view bounds.
+      var viewAreaTop:Float = this.scrollPositionInPixels - GRID_TOP_PAD;
+      var viewHeight:Float = (FlxG.height - MENU_BAR_HEIGHT);
+      var viewAreaBottom:Float = this.scrollPositionInPixels + viewHeight;
+
+      // Remove notes that are no longer visible and list the ones that are.
+      var displayedNoteData:Array<SongNoteData> = [];
+      for (noteSprite in renderedNotes.members)
+      {
+        if (noteSprite == null || !noteSprite.exists || !noteSprite.visible)
+          continue;
+
+        if (!noteSprite.isNoteVisible(viewAreaBottom, viewAreaTop))
+        {
+          // This sprite is off-screen.
+          // Kill the note sprite and recycle it.
+          noteSprite.noteData = null;
+        }
+        else if (currentSongChartNoteData.indexOf(noteSprite.noteData) == -1)
+        {
+          // This note was deleted.
+          // Kill the note sprite and recycle it.
+          noteSprite.noteData = null;
+        }
+        else if (noteSprite.noteData.length > 0 && (noteSprite.parentNoteSprite == null && noteSprite.childNoteSprite == null))
+        {
+          // Note was extended.
+          // Kill the note sprite and recycle it.
+          noteSprite.noteData = null;
+        }
+        else if (noteSprite.noteData.length == 0 && (noteSprite.parentNoteSprite != null || noteSprite.childNoteSprite != null))
+        {
+          // Note was shortened.
+          // Kill the note sprite and recycle it.
+          noteSprite.noteData = null;
+        }
+        else
+        {
+          // Note is already displayed and should remain displayed.
+          displayedNoteData.push(noteSprite.noteData);
+
+          // Update the note sprite's position.
+          noteSprite.updateNotePosition(renderedNotes);
+        }
+      }
+
+      // Remove events that are no longer visible and list the ones that are.
+      var displayedEventData:Array<SongEventData> = [];
+      for (eventSprite in renderedEvents.members)
+      {
+        if (eventSprite == null || !eventSprite.exists || !eventSprite.visible)
+          continue;
+
+        if (!eventSprite.isEventVisible(viewAreaBottom, viewAreaTop))
+        {
+          // This sprite is off-screen.
+          // Kill the event sprite and recycle it.
+          eventSprite.eventData = null;
+        }
+        else if (currentSongChartEventData.indexOf(eventSprite.eventData) == -1)
+        {
+          // This event was deleted.
+          // Kill the event sprite and recycle it.
+          eventSprite.eventData = null;
+        }
+        else
+        {
+          // Event is already displayed and should remain displayed.
+          displayedEventData.push(eventSprite.eventData);
+
+          // Update the event sprite's position.
+          eventSprite.updateEventPosition(renderedEvents);
+        }
+      }
+
+      // Add notes that are now visible.
+      for (noteData in currentSongChartNoteData)
+      {
+        // Remember if we are already displaying this note.
+        if (displayedNoteData.indexOf(noteData) != -1)
+        {
+          continue;
+        }
+
+        // Get the position the note should be at.
+        var noteTimePixels:Float = noteData.time / Conductor.stepCrochet * GRID_SIZE;
+
+        // Make sure the note appears when scrolling up.
+        var modifiedViewAreaTop = viewAreaTop - GRID_SIZE;
+
+        if (noteTimePixels < modifiedViewAreaTop || noteTimePixels > viewAreaBottom)
+          continue;
+
+        // Else, this note is visible and we need to render it!
+
+        // Get a note sprite from the pool.
+        // If we can reuse a deleted note, do so.
+        // If a new note is needed, call buildNoteSprite.
+        var noteSprite:ChartEditorNoteSprite = renderedNotes.recycle(() -> new ChartEditorNoteSprite(this));
+        noteSprite.parentState = this;
+
+        // The note sprite handles animation playback and positioning.
+        noteSprite.noteData = noteData;
+
+        // Setting note data resets position relative to the grid so we fix that.
+        noteSprite.x += renderedNotes.x;
+        noteSprite.y += renderedNotes.y;
+
+        if (noteSprite.noteData.length > 0)
+        {
+          // If the note is a hold, we need to make sure it's long enough.
+          var noteLengthMs:Float = noteSprite.noteData.length;
+          var noteLengthSteps:Float = (noteLengthMs / Conductor.stepCrochet);
+          var lastNoteSprite:ChartEditorNoteSprite = noteSprite;
+
+          while (noteLengthSteps > 0)
+          {
+            if (noteLengthSteps <= 1.0)
+            {
+              // Last note in the hold.
+              // TODO: We may need to make it shorter and clip it visually.
+            }
+
+            var nextNoteSprite:ChartEditorNoteSprite = renderedNotes.recycle(ChartEditorNoteSprite);
+            nextNoteSprite.parentState = this;
+            nextNoteSprite.parentNoteSprite = lastNoteSprite;
+            lastNoteSprite.childNoteSprite = nextNoteSprite;
+
+            lastNoteSprite = nextNoteSprite;
+
+            noteLengthSteps -= 1;
+          }
+
+          // Make sure the last note sprite shows the end cap properly.
+          lastNoteSprite.childNoteSprite = null;
+
+          // var noteLengthPixels:Float = (noteLengthMs / Conductor.stepCrochet + 1) * GRID_SIZE;
+          // add(new FlxSprite(noteSprite.x, noteSprite.y - renderedNotes.y + noteLengthPixels).makeGraphic(40, 2, 0xFFFF0000));
+        }
+      }
+
+      // Add events that are now visible.
+      for (eventData in currentSongChartEventData)
+      {
+        // Remember if we are already displaying this event.
+        if (displayedEventData.indexOf(eventData) != -1)
+        {
+          continue;
+        }
+
+        // Get the position the event should be at.
+        var eventTimePixels:Float = eventData.time / Conductor.stepCrochet * GRID_SIZE;
+
+        // Make sure the event appears when scrolling up.
+        var modifiedViewAreaTop = viewAreaTop - GRID_SIZE;
+
+        if (eventTimePixels < modifiedViewAreaTop || eventTimePixels > viewAreaBottom)
+          continue;
+
+        // Else, this event is visible and we need to render it!
+
+        // Get an event sprite from the pool.
+        // If we can reuse a deleted event, do so.
+        // If a new event is needed, call buildEventSprite.
+        var eventSprite:ChartEditorEventSprite = renderedEvents.recycle(() -> new ChartEditorEventSprite(this));
+        eventSprite.parentState = this;
+
+        // The event sprite handles animation playback and positioning.
+        eventSprite.eventData = eventData;
+
+        // Setting event data resets position relative to the grid so we fix that.
+        eventSprite.x += renderedEvents.x;
+        eventSprite.y += renderedEvents.y;
+      }
+
+      // Destroy all existing selection squares.
+      for (member in renderedSelectionSquares.members)
+      {
+        // Killing the sprite is cheap because we can recycle it.
+        member.kill();
+      }
+
+      // Readd selection squares for selected notes.
+      // Recycle selection squares if possible.
+      for (noteSprite in renderedNotes.members)
+      {
+        if (isNoteSelected(noteSprite.noteData) && noteSprite.parentNoteSprite == null)
+        {
+          var selectionSquare:FlxSprite = renderedSelectionSquares.recycle(buildSelectionSquare);
+
+          // Set the position and size (because we might be recycling one with bad values).
+          selectionSquare.x = noteSprite.x;
+          selectionSquare.y = noteSprite.y;
+          selectionSquare.width = noteSprite.width;
+          selectionSquare.height = noteSprite.height;
+        }
+      }
+
+      for (eventSprite in renderedEvents.members)
+      {
+        if (isEventSelected(eventSprite.eventData))
+        {
+          var selectionSquare:FlxSprite = renderedSelectionSquares.recycle(buildSelectionSquare);
+
+          // Set the position and size (because we might be recycling one with bad values).
+          selectionSquare.x = eventSprite.x;
+          selectionSquare.y = eventSprite.y;
+          selectionSquare.width = eventSprite.width;
+          selectionSquare.height = eventSprite.height;
+        }
+      }
+
+      // Sort the notes DESCENDING. This keeps the sustain behind the associated note.
+      renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING);
+
+      // Sort the events DESCENDING. This keeps the sustain behind the associated note.
+      renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING);
+    }
+  }
+
+  function buildSelectionSquare():FlxSprite
+  {
+    return new FlxSprite().loadGraphic(selectionSquareBitmap);
+  }
+
+  /**
+   * Handles display elements for the playbar at the bottom.
+   */
+  function handlePlaybar()
+  {
+    // Make sure the playbar is never nudged out of the correct spot.
+    playbarHeadLayout.x = 4;
+    playbarHeadLayout.y = FlxG.height - 48 - 8;
+
+    var songPos = Conductor.songPosition;
+    var songRemaining = songLengthInMs - songPos;
+
+    // Move the playhead to match the song position, if we aren't dragging it.
+    if (!playbarHeadDragging)
+    {
+      var songPosPercent:Float = songPos / songLengthInMs;
+      playbarHead.value = songPosPercent * 100;
+    }
+
+    var songPosSeconds:String = Std.string(Math.floor((songPos / 1000) % 60)).lpad('0', 2);
+    var songPosMinutes:String = Std.string(Math.floor((songPos / 1000) / 60)).lpad('0', 2);
+    var songPosString:String = '${songPosMinutes}:${songPosSeconds}';
+
+    setUIValue('playbarSongPos', songPosString);
+
+    var songRemainingSeconds:String = Std.string(Math.floor((songRemaining / 1000) % 60)).lpad('0', 2);
+    var songRemainingMinutes:String = Std.string(Math.floor((songRemaining / 1000) / 60)).lpad('0', 2);
+    var songRemainingString:String = '-${songRemainingMinutes}:${songRemainingSeconds}';
+
+    setUIValue('playbarSongRemaining', songRemainingString);
+  }
+
+  /**
+   * Handle keybinds for File menu items.
+   */
+  function handleFileKeybinds()
+  {
+    // CTRL + Q = Quit to Menu
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q)
+    {
+      FlxG.switchState(new MainMenuState());
+    }
+  }
+
+  /**
+   * Handle keybinds for edit menu items.
+   */
+  function handleEditKeybinds()
+  {
+    // CTRL + Z = Undo
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Z)
+    {
+      undoLastCommand();
+    }
+
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.Z && !FlxG.keys.pressed.Y)
+    {
+      undoHeldTime += FlxG.elapsed;
+    }
+    else
+    {
+      undoHeldTime = 0;
+    }
+    if (undoHeldTime > RAPID_UNDO_DELAY + RAPID_UNDO_INTERVAL)
+    {
+      undoLastCommand();
+      undoHeldTime -= RAPID_UNDO_INTERVAL;
+    }
+
+    // CTRL + Y = Redo
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Y)
+    {
+      redoLastCommand();
+    }
+
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.Y && !FlxG.keys.pressed.Z)
+    {
+      redoHeldTime += FlxG.elapsed;
+    }
+    else
+    {
+      redoHeldTime = 0;
+    }
+    if (redoHeldTime > RAPID_UNDO_DELAY + RAPID_UNDO_INTERVAL)
+    {
+      redoLastCommand();
+      redoHeldTime -= RAPID_UNDO_INTERVAL;
+    }
+
+    // CTRL + C = Copy
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.C)
+    {
+      // Copy selected notes.
+      // We don't need a command for this since we can't undo it.
+      SongDataUtils.writeItemsToClipboard({
+        notes: SongDataUtils.buildNoteClipboard(currentNoteSelection),
+        events: SongDataUtils.buildEventClipboard(currentEventSelection),
+      });
+    }
+
+    // CTRL + X = Cut
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.X)
+    {
+      // Cut selected notes.
+      performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection));
+    }
+
+    // CTRL + V = Paste
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.V)
+    {
+      // Paste notes from clipboard, at the playhead.
+      performCommand(new PasteItemsCommand(scrollPositionInMs + playheadPositionInMs));
+    }
+
+    // DELETE = Delete
+    if (FlxG.keys.justPressed.DELETE)
+    {
+      // Delete selected items.
+      if (currentNoteSelection.length > 0 && currentEventSelection.length > 0)
+      {
+        performCommand(new RemoveItemsCommand(currentNoteSelection, currentEventSelection));
+      }
+      else if (currentNoteSelection.length > 0)
+      {
+        performCommand(new RemoveNotesCommand(currentNoteSelection));
+      }
+      else if (currentEventSelection.length > 0)
+      {
+        performCommand(new RemoveEventsCommand(currentEventSelection));
+      }
+    }
+
+    // CTRL + A = Select All
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.A)
+    {
+      // Select all items.
+      performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection));
+    }
+
+    // CTRL + I = Select Inverse
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.I)
+    {
+      // Select unselected items and deselect selected items.
+      performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection));
+    }
+
+    // CTRL + D = Select None
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.D)
+    {
+      // Deselect all items.
+      performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
+    }
+  }
+
+  /**
+   * Handle keybinds for View menu items.
+   */
+  function handleViewKeybinds() {}
+
+  /**
+   * Handle keybinds for Help menu items.
+   */
+  function handleHelpKeybinds()
+  {
+    // F1 = Open Help
+    if (FlxG.keys.justPressed.F1)
+      ChartEditorDialogHandler.openUserGuideDialog(this);
+  }
+
+  function handleToolboxes()
+  {
+    handleDifficultyToolbox();
+    handlePlayerPreviewToolbox();
+    handleOpponentPreviewToolbox();
+  }
+
+  function handleDifficultyToolbox()
+  {
+    if (difficultySelectDirty)
+    {
+      difficultySelectDirty = false;
+
+      // Manage the Select Difficulty tree view.
+      var difficultyToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      if (difficultyToolbox == null)
+        return;
+
+      var treeView:TreeView = difficultyToolbox.findComponent('difficultyToolboxTree');
+      if (treeView == null)
+        return;
+
+      // Clear the tree view so we can rebuild it.
+      treeView.clearNodes();
+
+      var treeSong = treeView.addNode({id: 'stv_song', text: 'S: $currentSongName', icon: "haxeui-core/styles/default/haxeui_tiny.png"});
+      treeSong.expanded = true;
+
+      for (curVariation in availableVariations)
+      {
+        var variationMetadata:SongMetadata = songMetadata.get(curVariation);
+
+        var treeVariation = treeSong.addNode({
+          id: 'stv_variation_$curVariation',
+          text: 'V: ${curVariation.toTitleCase()}',
+          // icon: "haxeui-core/styles/default/haxeui_tiny.png"
+        });
+        treeVariation.expanded = true;
+
+        var difficultyList = variationMetadata.playData.difficulties;
+
+        for (difficulty in difficultyList)
+        {
+          var treeDifficulty = treeVariation.addNode({
+            id: 'stv_difficulty_${curVariation}_$difficulty',
+            text: 'D: ${difficulty.toTitleCase()}',
+            // icon: "haxeui-core/styles/default/haxeui_tiny.png"
+          });
+        }
+      }
+
+      treeView.onChange = onChangeTreeDifficulty;
+      treeView.selectedNode = getCurrentTreeDifficultyNode();
+    }
+  }
+
+  function handlePlayerPreviewToolbox()
+  {
+    // Manage the Select Difficulty tree view.
+    var charPreviewToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
+    if (charPreviewToolbox == null)
+      return;
+
+    var charPlayer:CharacterPlayer = charPreviewToolbox.findComponent('charPlayer');
+    if (charPlayer == null)
+      return;
+
+    currentPlayerCharacterPlayer = charPlayer;
+  }
+
+  function handleOpponentPreviewToolbox()
+  {
+    // Manage the Select Difficulty tree view.
+    var charPreviewToolbox = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
+    if (charPreviewToolbox == null)
+      return;
+
+    var charPlayer:CharacterPlayer = charPreviewToolbox.findComponent('charPlayer');
+    if (charPlayer == null)
+      return;
+
+    currentOpponentCharacterPlayer = charPlayer;
+  }
+
+  override function dispatchEvent(event:ScriptEvent)
+  {
+    super.dispatchEvent(event);
+
+    // We can't use the ScriptedEventDispatcher with currentCharPlayer because we can't use the IScriptedClass interface on it.
+    if (currentPlayerCharacterPlayer != null)
+    {
+      switch (event.type)
+      {
+        case ScriptEvent.UPDATE:
+          currentPlayerCharacterPlayer.onUpdate(cast event);
+        case ScriptEvent.SONG_BEAT_HIT:
+          currentPlayerCharacterPlayer.onBeatHit(cast event);
+        case ScriptEvent.SONG_STEP_HIT:
+          currentPlayerCharacterPlayer.onStepHit(cast event);
+        case ScriptEvent.NOTE_HIT:
+          currentPlayerCharacterPlayer.onNoteHit(cast event);
+      }
+    }
+
+    if (currentOpponentCharacterPlayer != null)
+    {
+      switch (event.type)
+      {
+        case ScriptEvent.UPDATE:
+          currentOpponentCharacterPlayer.onUpdate(cast event);
+        case ScriptEvent.SONG_BEAT_HIT:
+          currentOpponentCharacterPlayer.onBeatHit(cast event);
+        case ScriptEvent.SONG_STEP_HIT:
+          currentOpponentCharacterPlayer.onStepHit(cast event);
+        case ScriptEvent.NOTE_HIT:
+          currentOpponentCharacterPlayer.onNoteHit(cast event);
+      }
+    }
+  }
+
+  function getCurrentTreeDifficultyNode():TreeViewNode
+  {
+    var treeView:TreeView = findComponent('difficultyToolboxTree');
+
+    if (treeView == null)
+      return null;
+
+    var result = treeView.findNodeByPath('stv_song/stv_variation_$selectedVariation/stv_difficulty_${selectedVariation}_$selectedDifficulty', 'id');
+
+    if (result == null)
+      return null;
+
+    return result;
+  }
+
+  function onChangeTreeDifficulty(event:UIEvent):Void
+  {
+    // Get the newly selected node.
+    var treeView:TreeView = cast event.target;
+    var targetNode:TreeViewNode = treeView.selectedNode;
+
+    if (targetNode == null)
+    {
+      trace('No target node!');
+      // Reset the user's selection.
+      treeView.selectedNode = getCurrentTreeDifficultyNode();
+      return;
+    }
+
+    switch (targetNode.data.id.split('_')[1])
+    {
+      case 'difficulty':
+        var variation = targetNode.data.id.split('_')[2];
+        var difficulty = targetNode.data.id.split('_')[3];
+
+        if (variation != null && difficulty != null)
+        {
+          trace('Changing difficulty to $variation:$difficulty');
+          selectedVariation = variation;
+          selectedDifficulty = difficulty;
+        }
+      // case 'song':
+      // case 'variation':
+      default:
+        // Reset the user's selection.
+        trace('Selected wrong node type, resetting selection.');
+        treeView.selectedNode = getCurrentTreeDifficultyNode();
+    }
+  }
+
+  function addDifficulty(variation:String) {}
+
+  function addVariation(variationId:String)
+  {
+    // Create a new variation with the specified ID.
+    songMetadata.set(variationId, currentSongMetadata.clone(variationId));
+    // Switch to the new variation.
+    selectedVariation = variationId;
+  }
+
+  /**
+   * Handle the player preview/gameplay test area on the left side.
+   */
+  function handlePlayerDisplay() {}
+
+  /**
+   * Handles the note preview/scroll area on the right side.
+   * Notes are rendered here as small bars.
+   * This function also handles:
+   * - Moving the viewport preview box around based on its current position.
+   * - Scrolling the note preview area down if the note preview is taller than the screen,
+   *   and the viewport nears the end of the visible area.
+   */
+  function handleNotePreview()
+  {
+    //
+    if (notePreviewDirty)
+    {
+      notePreviewDirty = false;
+
+      var PREVIEW_WIDTH:Int = GRID_SIZE * 2;
+      var STEP_HEIGHT:Int = 1;
+      var PREVIEW_HEIGHT:Int = Std.int(Conductor.getTimeInSteps(audioInstTrack.length) * STEP_HEIGHT);
+
+      notePreviewBitmap = new BitmapData(PREVIEW_WIDTH, PREVIEW_HEIGHT, true);
+      notePreviewBitmap.fillRect(new Rectangle(0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT), PREVIEW_BG_COLOR);
+    }
+  }
+
+  /**
+   * Perform a spot update on the note preview, by editing the note preview
+   * only where necessary. More efficient than a full update.
+   */
+  function updateNotePreview(note:SongNoteData, ?deleteNote:Bool = false) {}
+
+  /**
+   * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status.
+   * Does not handle onClick ACTIONS of the menubar.
+   */
+  function handleMenubar()
+  {
+    if (commandHistoryDirty)
+    {
+      commandHistoryDirty = false;
+
+      // Update the Undo and Redo buttons.
+      var undoButton:MenuItem = findComponent('menubarItemUndo', MenuItem);
+
+      if (undoButton != null)
+      {
+        if (undoHistory.length == 0)
+        {
+          // Disable the Undo button.
+          undoButton.disabled = true;
+          undoButton.text = "Undo";
+        }
+        else
+        {
+          // Change the label to the last command.
+          undoButton.disabled = false;
+          undoButton.text = 'Undo ${undoHistory[undoHistory.length - 1].toString()}';
+        }
+      }
+      else
+      {
+        trace("undoButton is null");
+      }
+
+      var redoButton:MenuItem = findComponent('menubarItemRedo', MenuItem);
+
+      if (redoButton != null)
+      {
+        if (redoHistory.length == 0)
+        {
+          // Disable the Redo button.
+          redoButton.disabled = true;
+          redoButton.text = "Redo";
+        }
+        else
+        {
+          // Change the label to the last command.
+          redoButton.disabled = false;
+          redoButton.text = 'Redo ${redoHistory[redoHistory.length - 1].toString()}';
+        }
+      }
+      else
+      {
+        trace("redoButton is null");
+      }
+    }
+  }
+
+  /**
+   * Handle syncronizing the conductor with the music playback.
+   */
+  function handleMusicPlayback()
+  {
+    if (audioInstTrack != null && audioInstTrack.playing)
+    {
+      if (FlxG.mouse.pressedMiddle)
+      {
+        // If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat!
+
+        var oldStepTime = Conductor.currentStepTime;
+        var oldSongPosition = Conductor.songPosition;
+        Conductor.update(audioInstTrack.time);
+        handleHitsounds(oldSongPosition, Conductor.songPosition);
+        // Resync vocals.
+        if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
+          audioVocalTrackGroup.time = audioInstTrack.time;
+        var diffStepTime = Conductor.currentStepTime - oldStepTime;
+
+        // Move the playhead.
+        playheadPositionInPixels += diffStepTime * GRID_SIZE;
+
+        // We don't move the song to scroll position, or update the note sprites.
+      }
+      else
+      {
+        // Else, move the entire view.
+        var oldSongPosition = Conductor.songPosition;
+        Conductor.update(audioInstTrack.time);
+        handleHitsounds(oldSongPosition, Conductor.songPosition);
+        // Resync vocals.
+        if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
+          audioVocalTrackGroup.time = audioInstTrack.time;
+
+        // We need time in fractional steps here to allow the song to actually play.
+        // Also account for a potentially offset playhead.
+        scrollPositionInPixels = Conductor.currentStepTime * GRID_SIZE - playheadPositionInPixels;
+
+        // DO NOT move song to scroll position here specifically.
+
+        // We need to update the note sprites.
+        noteDisplayDirty = true;
+      }
+    }
+
+    if (FlxG.keys.justPressed.SPACE && !isHaxeUIDialogOpen)
+    {
+      toggleAudioPlayback();
+    }
+  }
+
+  /**
+   * Handle the playback of hitsounds.
+   */
+  function handleHitsounds(oldSongPosition:Float, newSongPosition:Float):Void
+  {
+    if (!hitsoundsEnabled)
+      return;
+
+    // Assume notes are sorted by time.
+    for (noteData in currentSongChartNoteData)
+    {
+      if (noteData.time < oldSongPosition)
+        // Note is in the past.
+        continue;
+
+      if (noteData.time >= newSongPosition)
+        // Note is in the future.
+        return;
+
+      // Note was just hit.
+
+      // Character preview.
+
+      // Why does NOTESCRIPTEVENT TAKE A SPRITE AAAAA
+      var tempNote:Note = new Note(noteData.time, noteData.data, null, false, NORMAL);
+      tempNote.mustPress = noteData.getMustHitNote();
+      tempNote.data.sustainLength = noteData.length;
+      tempNote.data.noteKind = noteData.kind;
+      tempNote.scrollFactor.set(0, 0);
+      var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, tempNote, 1, true);
+      dispatchEvent(event);
+
+      // Calling event.cancelEvent() skips all the other logic! Neat!
+      if (event.eventCanceled)
+        continue;
+
+      // Hitsounds.
+      switch (noteData.getStrumlineIndex())
+      {
+        case 0: // Player
+          if (hitsoundsEnabledPlayer)
+            playSound(Paths.sound('funnyNoise/funnyNoise-09'));
+        case 1: // Opponent
+          if (hitsoundsEnabledOpponent)
+            playSound(Paths.sound('funnyNoise/funnyNoise-010'));
+      }
+    }
+  }
+
+  function startAudioPlayback()
+  {
+    if (audioInstTrack != null)
+      audioInstTrack.play();
+    if (audioVocalTrackGroup != null)
+      audioVocalTrackGroup.play();
+    if (audioVocalTrackGroup != null)
+      audioVocalTrackGroup.play();
+  }
+
+  function stopAudioPlayback()
+  {
+    if (audioInstTrack != null)
+      audioInstTrack.pause();
+    if (audioVocalTrackGroup != null)
+      audioVocalTrackGroup.pause();
+    if (audioVocalTrackGroup != null)
+      audioVocalTrackGroup.pause();
+  }
+
+  function toggleAudioPlayback()
+  {
+    if (audioInstTrack == null)
+      return;
+
+    if (audioInstTrack.playing)
+    {
+      stopAudioPlayback();
+    }
+    else
+    {
+      startAudioPlayback();
+    }
+  }
+
+  function handlePlayhead()
+  {
+    // Place notes at the playhead.
+    // TODO: Add the ability to switch modes.
+    if (true)
+    {
+      if (FlxG.keys.justPressed.ONE)
+        placeNoteAtPlayhead(0);
+      if (FlxG.keys.justPressed.TWO)
+        placeNoteAtPlayhead(1);
+      if (FlxG.keys.justPressed.THREE)
+        placeNoteAtPlayhead(2);
+      if (FlxG.keys.justPressed.FOUR)
+        placeNoteAtPlayhead(3);
+      if (FlxG.keys.justPressed.FIVE)
+        placeNoteAtPlayhead(4);
+      if (FlxG.keys.justPressed.SIX)
+        placeNoteAtPlayhead(5);
+      if (FlxG.keys.justPressed.SEVEN)
+        placeNoteAtPlayhead(6);
+      if (FlxG.keys.justPressed.EIGHT)
+        placeNoteAtPlayhead(7);
+    }
+  }
+
+  function placeNoteAtPlayhead(column:Int):Void
+  {
+    var gridSnappedPlayheadPos = scrollPositionInPixels - (scrollPositionInPixels % GRID_SIZE);
+  }
+
+  function set_scrollPositionInPixels(value:Float):Float
+  {
+    if (value < 0)
+    {
+      // If we're scrolling up, and we hit the top,
+      // but the playhead is in the middle, move the playhead up.
+      if (playheadPositionInPixels > 0)
+      {
+        var amount = scrollPositionInPixels - value;
+        playheadPositionInPixels -= amount;
+      }
+
+      value = 0;
+    }
+
+    if (value > songLengthInPixels)
+      value = songLengthInPixels;
+
+    if (value == scrollPositionInPixels)
+      return value;
+
+    this.scrollPositionInPixels = value;
+
+    // Move the grid sprite to the correct position.
+    if (isViewDownscroll)
+    {
+      gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
+    }
+    else
+    {
+      gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
+    }
+    // Move the rendered notes to the correct position.
+    renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y);
+    renderedEvents.setPosition(gridTiledSprite.x, gridTiledSprite.y);
+    renderedSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y);
+    if (gridSpectrogram != null)
+    {
+      // Move the spectrogram to the correct position.
+      gridSpectrogram.y = gridTiledSprite.y;
+      gridSpectrogram.setPosition(0, 0);
+    }
+
+    return this.scrollPositionInPixels;
+  }
+
+  function get_playheadPositionInPixels():Float
+  {
+    return this.playheadPositionInPixels;
+  }
+
+  function set_playheadPositionInPixels(value:Float):Float
+  {
+    // Make sure playhead doesn't go outside the song.
+    if (value + scrollPositionInPixels < 0)
+      value = -scrollPositionInPixels;
+    if (value + scrollPositionInPixels > songLengthInPixels)
+      value = songLengthInPixels - scrollPositionInPixels;
+
+    this.playheadPositionInPixels = value;
+
+    // Move the playhead sprite to the correct position.
+    gridPlayhead.y = this.playheadPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
+
+    return this.playheadPositionInPixels;
+  }
+
+  /**
+   * Loads an instrumental from an absolute file path, replacing the current instrumental.
+   */
+  public function loadInstrumentalFromPath(path:String):Void
+  {
+    #if sys
+    var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path);
+    loadInstrumentalFromBytes(fileBytes);
+    #else
+    trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
+    #end
+  }
+
+  /**
+   * Loads an instrumental from audio byte data, replacing the current instrumental.
+   */
+  public function loadInstrumentalFromBytes(bytes:haxe.io.Bytes):Void
+  {
+    var openflSound = new openfl.media.Sound();
+    openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length);
+    audioInstTrack = FlxG.sound.load(openflSound, 1.0, false);
+    audioInstTrack.autoDestroy = false;
+    audioInstTrack.pause();
+
+    // Tell the user the load was successful.
+    // TODO: Un-bork this.
+    // showNotification('Loaded instrumental track successfully.');
+
+    postLoadInstrumental();
+  }
+
+  public function loadInstrumentalFromAsset(path:String):Void
+  {
+    var instTrack = FlxG.sound.load(path, 1.0, false);
+    audioInstTrack = instTrack;
+
+    postLoadInstrumental();
+  }
+
+  function postLoadInstrumental()
+  {
+    // Prevent the time from skipping back to 0 when the song ends.
+    audioInstTrack.onComplete = function()
+    {
+      if (audioInstTrack != null)
+        audioInstTrack.pause();
+      if (audioVocalTrackGroup != null)
+        audioVocalTrackGroup.pause();
+    };
+
+    songLengthInMs = audioInstTrack.length;
+
+    gridTiledSprite.height = songLengthInPixels;
+    if (gridSpectrogram != null)
+    {
+      gridSpectrogram.setSound(audioInstTrack);
+      gridSpectrogram.generateSection(0, songLengthInMs / 1000);
+    }
+
+    scrollPositionInPixels = 0;
+    playheadPositionInPixels = 0;
+    moveSongToScrollPosition();
+  }
+
+  /**
+   * Loads a vocal track from an absolute file path.
+   */
+  public function loadVocalsFromPath(path:String):Void
+  {
+    #if sys
+    var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path);
+    loadVocalsFromBytes(fileBytes);
+    #else
+    trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
+    #end
+  }
+
+  public function loadVocalsFromAsset(path:String):Void
+  {
+    var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
+    audioVocalTrackGroup.add(vocalTrack);
+  }
+
+  /**
+   * Loads a vocal track from audio byte data.
+   */
+  public function loadVocalsFromBytes(bytes:haxe.io.Bytes):Void
+  {
+    var openflSound = new openfl.media.Sound();
+    openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length);
+    var vocalTrack:FlxSound = FlxG.sound.load(openflSound, 1.0, false);
+    audioVocalTrackGroup.add(vocalTrack);
+
+    // Tell the user the load was successful.
+    // TODO: Un-bork this.
+    // showNotification('Loaded instrumental track successfully.');
+  }
+
+  /**
+   * Fetch's a song's existing chart and audio and loads it, replacing the current song.
+   */
+  public function loadSongAsTemplate(songId:String)
+  {
+    var song:Song = SongDataParser.fetchSong(songId);
+
+    if (song == null)
+    {
+      // showNotification('Failed to load song template.');
+      return;
+    }
+
+    // Load the song metadata.
+    var rawSongMetadata:Array<SongMetadata> = song.getRawMetadata();
+
+    this.songMetadata = new Map<String, SongMetadata>();
+
+    for (metadata in rawSongMetadata)
+    {
+      var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation;
+      this.songMetadata.set(variation, metadata);
+    }
+
+    this.songChartData = new Map<String, SongChartData>();
+
+    for (metadata in rawSongMetadata)
+    {
+      var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation;
+      this.songChartData.set(variation, SongDataParser.parseSongChartData(songId, metadata.variation));
+    }
+
+    Conductor.forceBPM(null); // Disable the forced BPM.
+    Conductor.mapTimeChanges(currentSongMetadata.timeChanges);
+
+    loadInstrumentalFromAsset(Paths.inst(songId));
+    loadVocalsFromAsset(Paths.voices(songId));
+
+    // showNotification('Loaded song ${songId}.');
+  }
+
+  /**
+   * When setting the scroll position, except when automatically scrolling during song playback,
+   * we need to update the conductor's current step time and the timestamp of the audio tracks.
+   */
+  function moveSongToScrollPosition()
+  {
+    // Update the songPosition in the Conductor.
+    Conductor.update(scrollPositionInMs);
+
+    // Update the songPosition in the audio tracks.
+    if (audioInstTrack != null)
+      audioInstTrack.time = scrollPositionInMs + playheadPositionInMs;
+    if (audioVocalTrackGroup != null)
+      audioVocalTrackGroup.time = scrollPositionInMs + playheadPositionInMs;
+
+    // We need to update the note sprites because we changed the scroll position.
+    noteDisplayDirty = true;
+  }
+
+  /**
+   * Perform (or redo) a command, then add it to the undo stack.
+   * 
+   * @param command The command to perform.
+   * @param purgeRedoStack If true, the redo stack will be cleared.
+   */
+  function performCommand(command:ChartEditorCommand, ?purgeRedoStack:Bool = true):Void
+  {
+    command.execute(this);
+    undoHistory.push(command);
+    commandHistoryDirty = true;
+    if (purgeRedoStack)
+      redoHistory = [];
+  }
+
+  /**
+   * Undo a command, then add it to the redo stack.
+   * @param command The command to undo.
+   */
+  function undoCommand(command:ChartEditorCommand):Void
+  {
+    command.undo(this);
+    redoHistory.push(command);
+    commandHistoryDirty = true;
+  }
+
+  /**
+   * Undo the last command in the undo stack, then add it to the redo stack.
+   */
+  function undoLastCommand():Void
+  {
+    if (undoHistory.length == 0)
+    {
+      trace('No actions to undo.');
+      return;
+    }
+
+    var command = undoHistory.pop();
+    undoCommand(command);
+  }
+
+  /**
+   * Redo the last command in the redo stack, then add it to the undo stack.
+   */
+  function redoLastCommand():Void
+  {
+    if (redoHistory.length == 0)
+    {
+      trace('No actions to redo.');
+      return;
+    }
+
+    var command = redoHistory.pop();
+    performCommand(command, false);
+  }
+
+  function sortChartData()
+  {
+    currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int
+    {
+      return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
+    });
+
+    currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int
+    {
+      return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
+    });
+  }
+
+  function playMetronomeTick(?high:Bool = false)
+  {
+    playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}'));
+  }
+
+  function isNoteSelected(note:SongNoteData):Bool
+  {
+    return currentNoteSelection.indexOf(note) != -1;
+  }
+
+  function isEventSelected(event:SongEventData):Bool
+  {
+    return currentEventSelection.indexOf(event) != -1;
+  }
+
+  /**
+   * Play a sound effect.
+   * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance.
+   */
+  function playSound(path:String)
+  {
+    var snd:FlxSound = FlxG.sound.list.recycle(FlxSound);
+    snd.loadEmbedded(FlxG.sound.cache(path));
+    snd.autoDestroy = true;
+    FlxG.sound.list.add(snd);
+    snd.play();
+  }
+
+  override function destroy()
+  {
+    super.destroy();
+
+    cleanupAutoSave();
+
+    @:privateAccess
+    ChartEditorNoteSprite.noteFrameCollection = null;
+  }
+
+  /**
+   * Displays a notification to the user. The only action is to dismiss.
+   */
+  function showNotification(text:String)
+  {
+    // Make it appear.
+    notifBar.show();
+
+    // Auto dismiss.
+    new FlxTimer().start(NOTIFICATION_DISMISS_TIME, (_:FlxTimer) -> dismissNotification());
+  }
+
+  /**
+   * Dismiss any existing notifications, if there are any.
+   */
+  function dismissNotification():Void
+  {
+    notifBar.hide();
+  }
+
+  /**
+   * @param force Whether to force the export without prompting the user for a file location.
+   * @param tmp If true, save to the temporary directory instead of the local `backup` directory.
+   */
+  public function exportAllSongData(?force:Bool = false, ?tmp:Bool = false):Void
+  {
+    var zipEntries = [];
+
+    for (variation in availableVariations)
+    {
+      var variationId = variation;
+      if (variation == '' || variation == 'default' || variation == 'normal')
+      {
+        variationId = '';
+      }
+
+      if (variationId == '')
+      {
+        var variationMetadata = songMetadata.get(variation);
+        zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata.json', SerializerUtil.toJSON(variationMetadata)));
+        var variationChart = songChartData.get(variation);
+        zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart.json', SerializerUtil.toJSON(variationChart)));
+      }
+      else
+      {
+        var variationMetadata = songMetadata.get(variation);
+        zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata-$variationId.json', SerializerUtil.toJSON(variationMetadata)));
+        var variationChart = songChartData.get(variation);
+        zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart-$variationId.json', SerializerUtil.toJSON(variationChart)));
+      }
+    }
+
+    // TODO: Add audio files to the ZIP.
+
+    trace('Exporting ${zipEntries.length} files to ZIP...');
+
+    if (force)
+    {
+      var targetPath:String = tmp ? Path.join([FileUtil.getTempDir(), 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']) : Path.join(['./backups/', 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']);
+
+      // We have to force write because the program will die before the save dialog is closed.
+      trace('Force exporting to $targetPath...');
+      FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath);
+      return;
+    }
+
+    // Prompt and save.
+    var onSave:Array<String>->Void = (paths:Array<String>) ->
+    {
+      trace('Successfully exported files.');
+    };
+
+    var onCancel:Void->Void = () ->
+    {
+      trace('Export cancelled.');
+    };
+
+    FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '$currentSongId-chart.zip');
+  }
 }
diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
index ab45e2e90..88bb9eca4 100644
--- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
@@ -1,17 +1,25 @@
 package funkin.ui.debug.charting;
 
-import funkin.play.song.SongData.SongTimeChange;
-import haxe.ui.components.Slider;
-import haxe.ui.components.NumberStepper;
-import haxe.ui.components.NumberStepper;
-import haxe.ui.components.TextField;
+import haxe.ui.data.ArrayDataSource;
 import funkin.play.character.BaseCharacter.CharacterType;
-import funkin.ui.haxeui.components.CharacterPlayer;
+import funkin.play.event.SongEvent;
+import funkin.play.song.SongData.SongTimeChange;
 import funkin.play.song.SongSerializer;
+import funkin.ui.haxeui.components.CharacterPlayer;
 import haxe.ui.components.Button;
+import haxe.ui.components.CheckBox;
 import haxe.ui.components.DropDown;
-import haxe.ui.containers.Group;
+import haxe.ui.components.Label;
+import haxe.ui.components.NumberStepper;
+import haxe.ui.components.NumberStepper;
+import haxe.ui.components.Slider;
+import haxe.ui.components.TextField;
 import haxe.ui.containers.dialogs.Dialog;
+import haxe.ui.containers.Box;
+import haxe.ui.containers.Frame;
+import haxe.ui.containers.Grid;
+import haxe.ui.containers.Group;
+import haxe.ui.core.Component;
 import haxe.ui.events.UIEvent;
 
 /**
@@ -19,415 +27,556 @@ import haxe.ui.events.UIEvent;
  */
 enum ChartEditorToolMode
 {
-	Select;
-	Place;
+  Select;
+  Place;
 }
 
 class ChartEditorToolboxHandler
 {
-	public static function setToolboxState(state:ChartEditorState, id:String, shown:Bool):Void
-	{
-		if (shown)
-			showToolbox(state, id);
-		else
-			hideToolbox(state, id);
-	}
-
-	public static function showToolbox(state:ChartEditorState, id:String)
-	{
-		var toolbox:Dialog = state.activeToolboxes.get(id);
-
-		if (toolbox == null)
-			toolbox = initToolbox(state, id);
-
-		if (toolbox != null)
-		{
-			toolbox.showDialog(false);
-		}
-		else
-		{
-			trace('ChartEditorToolboxHandler.showToolbox() - Could not retrieve toolbox: $id');
-		}
-	}
-
-	public static function hideToolbox(state:ChartEditorState, id:String):Void
-	{
-		var toolbox:Dialog = state.activeToolboxes.get(id);
-
-		if (toolbox == null)
-			toolbox = initToolbox(state, id);
-
-		if (toolbox != null)
-		{
-			toolbox.hideDialog(DialogButton.CANCEL);
-		}
-		else
-		{
-			trace('ChartEditorToolboxHandler.hideToolbox() - Could not retrieve toolbox: $id');
-		}
-	}
-
-	public static function minimizeToolbox(state:ChartEditorState, id:String):Void
-	{
-	}
-
-	public static function maximizeToolbox(state:ChartEditorState, id:String):Void
-	{
-	}
-
-	public static function initToolbox(state:ChartEditorState, id:String):Dialog
-	{
-		var toolbox:Dialog = null;
-		switch (id)
-		{
-			case ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT:
-				toolbox = buildToolboxToolsLayout(state);
-			case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:
-				toolbox = buildToolboxNoteDataLayout(state);
-			case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:
-				toolbox = buildToolboxEventDataLayout(state);
-			case ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:
-				toolbox = buildToolboxDifficultyLayout(state);
-			case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
-				toolbox = buildToolboxMetadataLayout(state);
-			case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:
-				toolbox = buildToolboxCharactersLayout(state);
-			case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
-				toolbox = buildToolboxPlayerPreviewLayout(state);
-			case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
-				toolbox = buildToolboxOpponentPreviewLayout(state);
-			default:
-				trace('ChartEditorToolboxHandler.initToolbox() - Unknown toolbox ID: $id');
-				toolbox = null;
-		}
-
-		// Make sure we can reuse the toolbox later.
-		toolbox.destroyOnClose = false;
-		state.activeToolboxes.set(id, toolbox);
-
-		return toolbox;
-	}
-
-	public static function getToolbox(state:ChartEditorState, id:String):Dialog
-	{
-		var toolbox:Dialog = state.activeToolboxes.get(id);
-
-		// Initialize the toolbox without showing it.
-		if (toolbox == null)
-			toolbox = initToolbox(state, id);
-
-		return toolbox;
-	}
-
-	static function buildToolboxToolsLayout(state:ChartEditorState):Dialog
-	{
-		var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT);
-
-		if (toolbox == null) return null;
-
-		// Starting position.
-		toolbox.x = 50;
-		toolbox.y = 50;
-
-		toolbox.onDialogClosed = (event:DialogEvent) ->
-		{
-			state.setUICheckboxSelected('menubarItemToggleToolboxTools', false);
-		}
-
-		var toolsGroup:Group = toolbox.findComponent("toolboxToolsGroup", Group);
-
-		if (toolsGroup == null) return null;
-
-		toolsGroup.onChange = (event:UIEvent) ->
-		{
-			switch (event.target.id)
-			{
-				case 'toolboxToolsGroupSelect':
-					state.currentToolMode = ChartEditorToolMode.Select;
-				case 'toolboxToolsGroupPlace':
-					state.currentToolMode = ChartEditorToolMode.Place;
-				default:
-					trace('ChartEditorToolboxHandler.buildToolboxToolsLayout() - Unknown toolbox tool selected: $event.target.id');
-			}
-		}
-
-		return toolbox;
-	}
-
-	static function buildToolboxNoteDataLayout(state:ChartEditorState):Dialog
-	{
-		var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT);
-
-		if (toolbox == null) return null;
-
-		// Starting position.
-		toolbox.x = 75;
-		toolbox.y = 100;
-
-		toolbox.onDialogClosed = (event:DialogEvent) ->
-		{
-			state.setUICheckboxSelected('menubarItemToggleToolboxNotes', false);
-		}
-
-		var toolboxNotesNoteKind:DropDown = toolbox.findComponent("toolboxNotesNoteKind", DropDown);
-
-		toolboxNotesNoteKind.onChange = (event:UIEvent) ->
-		{
-			state.selectedNoteKind = event.data.id;
-		}
-
-		return toolbox;
-	}
-
-	static function buildToolboxEventDataLayout(state:ChartEditorState):Dialog
-	{
-		var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT);
-
-		if (toolbox == null) return null;
-
-		// Starting position.
-		toolbox.x = 100;
-		toolbox.y = 150;
-
-		toolbox.onDialogClosed = (event:DialogEvent) ->
-		{
-			state.setUICheckboxSelected('menubarItemToggleToolboxEvents', false);
-		}
-
-		return toolbox;
-	}
-
-	static function buildToolboxDifficultyLayout(state:ChartEditorState):Dialog
-	{
-		var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
-
-		if (toolbox == null) return null;
-
-		// Starting position.
-		toolbox.x = 125;
-		toolbox.y = 200;
-
-		toolbox.onDialogClosed = (event:DialogEvent) ->
-		{
-			state.setUICheckboxSelected('menubarItemToggleToolboxDifficulty', false);
-		}
-
-		var difficultyToolboxSaveMetadata:Button = toolbox.findComponent("difficultyToolboxSaveMetadata", Button);
-		var difficultyToolboxSaveChart:Button = toolbox.findComponent("difficultyToolboxSaveChart", Button);
-		var difficultyToolboxSaveAll:Button = toolbox.findComponent("difficultyToolboxSaveAll", Button);
-		var difficultyToolboxLoadMetadata:Button = toolbox.findComponent("difficultyToolboxLoadMetadata", Button);
-		var difficultyToolboxLoadChart:Button = toolbox.findComponent("difficultyToolboxLoadChart", Button);
-
-		difficultyToolboxSaveMetadata.onClick = (event:UIEvent) ->
-		{
-			SongSerializer.exportSongMetadata(state.currentSongMetadata);
-		};
-		
-		difficultyToolboxSaveChart.onClick = (event:UIEvent) ->
-		{
-			SongSerializer.exportSongChartData(state.currentSongChartData);
-		};
-		
-		difficultyToolboxSaveAll.onClick = (event:UIEvent) ->
-		{
-			state.exportAllSongData();
-		};
-
-		difficultyToolboxLoadMetadata.onClick = (event:UIEvent) ->
-		{
-			// Replace metadata for current variation.
-			SongSerializer.importSongMetadataAsync(function(songMetadata)
-			{
-				state.currentSongMetadata = songMetadata;
-			});
-		};
-		
-		difficultyToolboxLoadChart.onClick = (event:UIEvent) ->
-		{
-			// Replace chart data for current variation.
-			SongSerializer.importSongChartDataAsync(function(songChartData)
-			{
-				state.currentSongChartData = songChartData;
-				state.noteDisplayDirty = true;
-			});
-		};
-
-		state.difficultySelectDirty = true;
-
-		return toolbox;
-	}
-
-	static function buildToolboxMetadataLayout(state:ChartEditorState):Dialog
-	{
-		var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
-
-		if (toolbox == null) return null;
-
-		// Starting position.
-		toolbox.x = 150;
-		toolbox.y = 250;
-
-		toolbox.onDialogClosed = (event:DialogEvent) ->
-		{
-			state.setUICheckboxSelected('menubarItemToggleToolboxMetadata', false);
-		}
-
-		var inputSongName:TextField = toolbox.findComponent('inputSongName', TextField);
-		inputSongName.onChange = (event:UIEvent) ->
-		{
-			var valid = event.target.text != null && event.target.text != "";
-
-			if (valid)
-			{
-				inputSongName.removeClass('invalid-value');
-				state.currentSongMetadata.songName = event.target.text;
-			}
-			else
-			{
-				state.currentSongMetadata.songName = null;
-			}
-		};
-
-		var inputSongArtist:TextField = toolbox.findComponent('inputSongArtist', TextField);
-		inputSongArtist.onChange = (event:UIEvent) ->
-		{
-			var valid = event.target.text != null && event.target.text != "";
-
-			if (valid)
-			{
-				inputSongArtist.removeClass('invalid-value');
-				state.currentSongMetadata.artist = event.target.text;
-			}
-			else
-			{
-				state.currentSongMetadata.artist = null;
-			}
-		};
-
-		var inputStage:DropDown = toolbox.findComponent('inputStage', DropDown);
-		inputStage.onChange = (event:UIEvent) ->
-		{
-			var valid = event.data != null && event.data.id != null;
-
-			if (valid) {
-				state.currentSongMetadata.playData.stage = event.data.id;
-			}
-		};
-
-		var inputNoteSkin:DropDown = toolbox.findComponent('inputNoteSkin', DropDown);
-		inputNoteSkin.onChange = (event:UIEvent) ->
-		{
-			if (event.data.id == null)
-				return;
-			state.currentSongMetadata.playData.noteSkin = event.data.id;
-		};
-
-		var inputBPM:NumberStepper = toolbox.findComponent('inputBPM', NumberStepper);
-		inputBPM.onChange = (event:UIEvent) ->
-		{
-			if (event.value == null || event.value <= 0)
-				return;
-
-			var timeChanges = state.currentSongMetadata.timeChanges;
-			if (timeChanges == null || timeChanges.length == 0)
-			{
-				timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])];
-			}
-			else
-			{
-				timeChanges[0].bpm = event.value;
-			}
-
-			Conductor.forceBPM(event.value);
-
-			state.currentSongMetadata.timeChanges = timeChanges;
-		};
-
-		var inputScrollSpeed:Slider = toolbox.findComponent('inputScrollSpeed', Slider);
-		inputScrollSpeed.onChange = (event:UIEvent) ->
-		{
-			var valid = event.target.value != null && event.target.value > 0;
-
-			if (valid)
-			{
-				inputScrollSpeed.removeClass('invalid-value');
-				state.currentSongChartData.scrollSpeed = event.target.value;
-			}
-			else
-			{
-				state.currentSongChartData.scrollSpeed = null;
-			}
-		};
-
-
-		return toolbox;
-	}
-
-	static function buildToolboxCharactersLayout(state:ChartEditorState):Dialog
-	{
-		var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT);
-
-		if (toolbox == null) return null;
-
-		// Starting position.
-		toolbox.x = 175;
-		toolbox.y = 300;
-
-		toolbox.onDialogClosed = (event:DialogEvent) ->
-		{
-			state.setUICheckboxSelected('menubarItemToggleToolboxCharacters', false);
-		}
-
-		return toolbox;
-	}
-
-	static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Dialog
-	{
-		var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
-
-		if (toolbox == null) return null;
-
-		// Starting position.
-		toolbox.x = 200;
-		toolbox.y = 350;
-
-		toolbox.onDialogClosed = (event:DialogEvent) ->
-		{
-			state.setUICheckboxSelected('menubarItemToggleToolboxPlayerPreview', false);
-		}
-
-		var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer');
-		// TODO: We need to implement character swapping in ChartEditorState.
-		charPlayer.loadCharacter('bf');
-		//charPlayer.setScale(0.5);
-		charPlayer.setCharacterType(CharacterType.BF);
-		charPlayer.flip = true;
-
-		return toolbox;
-	}
-
-	static function buildToolboxOpponentPreviewLayout(state:ChartEditorState):Dialog
-	{
-		var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
-
-		if (toolbox == null) return null;
-
-		// Starting position.
-		toolbox.x = 200;
-		toolbox.y = 350;
-
-		toolbox.onDialogClosed = (event:DialogEvent) ->
-		{
-			state.setUICheckboxSelected('menubarItemToggleToolboxOpponentPreview', false);
-		}
-
-		var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer');
-		// TODO: We need to implement character swapping in ChartEditorState.
-		charPlayer.loadCharacter('dad');
-		// charPlayer.setScale(0.5);
-		charPlayer.setCharacterType(CharacterType.DAD);
-		charPlayer.flip = false;
-
-		return toolbox;
-	}
+  public static function setToolboxState(state:ChartEditorState, id:String, shown:Bool):Void
+  {
+    if (shown)
+      showToolbox(state, id);
+    else
+      hideToolbox(state, id);
+  }
+
+  public static function showToolbox(state:ChartEditorState, id:String)
+  {
+    var toolbox:Dialog = state.activeToolboxes.get(id);
+
+    if (toolbox == null)
+      toolbox = initToolbox(state, id);
+
+    if (toolbox != null)
+    {
+      toolbox.showDialog(false);
+    }
+    else
+    {
+      trace('ChartEditorToolboxHandler.showToolbox() - Could not retrieve toolbox: $id');
+    }
+  }
+
+  public static function hideToolbox(state:ChartEditorState, id:String):Void
+  {
+    var toolbox:Dialog = state.activeToolboxes.get(id);
+
+    if (toolbox == null)
+      toolbox = initToolbox(state, id);
+
+    if (toolbox != null)
+    {
+      toolbox.hideDialog(DialogButton.CANCEL);
+    }
+    else
+    {
+      trace('ChartEditorToolboxHandler.hideToolbox() - Could not retrieve toolbox: $id');
+    }
+  }
+
+  public static function minimizeToolbox(state:ChartEditorState, id:String):Void {}
+
+  public static function maximizeToolbox(state:ChartEditorState, id:String):Void {}
+
+  public static function initToolbox(state:ChartEditorState, id:String):Dialog
+  {
+    var toolbox:Dialog = null;
+    switch (id)
+    {
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT:
+        toolbox = buildToolboxToolsLayout(state);
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:
+        toolbox = buildToolboxNoteDataLayout(state);
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:
+        toolbox = buildToolboxEventDataLayout(state);
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:
+        toolbox = buildToolboxDifficultyLayout(state);
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
+        toolbox = buildToolboxMetadataLayout(state);
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:
+        toolbox = buildToolboxCharactersLayout(state);
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
+        toolbox = buildToolboxPlayerPreviewLayout(state);
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
+        toolbox = buildToolboxOpponentPreviewLayout(state);
+      default:
+        // This happens if you try to load an unknown layout.
+        trace('ChartEditorToolboxHandler.initToolbox() - Unknown toolbox ID: $id');
+        toolbox = null;
+    }
+
+    // This happens if the layout you try to load has a syntax error.
+    if (toolbox == null)
+      return null;
+
+    // Make sure we can reuse the toolbox later.
+    toolbox.destroyOnClose = false;
+    state.activeToolboxes.set(id, toolbox);
+
+    return toolbox;
+  }
+
+  public static function getToolbox(state:ChartEditorState, id:String):Dialog
+  {
+    var toolbox:Dialog = state.activeToolboxes.get(id);
+
+    // Initialize the toolbox without showing it.
+    if (toolbox == null)
+      toolbox = initToolbox(state, id);
+
+    return toolbox;
+  }
+
+  static function buildToolboxToolsLayout(state:ChartEditorState):Dialog
+  {
+    var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT);
+
+    if (toolbox == null)
+      return null;
+
+    // Starting position.
+    toolbox.x = 50;
+    toolbox.y = 50;
+
+    toolbox.onDialogClosed = (event:DialogEvent) ->
+    {
+      state.setUICheckboxSelected('menubarItemToggleToolboxTools', false);
+    }
+
+    var toolsGroup:Group = toolbox.findComponent("toolboxToolsGroup", Group);
+
+    if (toolsGroup == null)
+      return null;
+
+    toolsGroup.onChange = (event:UIEvent) ->
+    {
+      switch (event.target.id)
+      {
+        case 'toolboxToolsGroupSelect':
+          state.currentToolMode = ChartEditorToolMode.Select;
+        case 'toolboxToolsGroupPlace':
+          state.currentToolMode = ChartEditorToolMode.Place;
+        default:
+          trace('ChartEditorToolboxHandler.buildToolboxToolsLayout() - Unknown toolbox tool selected: $event.target.id');
+      }
+    }
+
+    return toolbox;
+  }
+
+  static function buildToolboxNoteDataLayout(state:ChartEditorState):Dialog
+  {
+    var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT);
+
+    if (toolbox == null)
+      return null;
+
+    // Starting position.
+    toolbox.x = 75;
+    toolbox.y = 100;
+
+    toolbox.onDialogClosed = (event:DialogEvent) ->
+    {
+      state.setUICheckboxSelected('menubarItemToggleToolboxNotes', false);
+    }
+
+    var toolboxNotesNoteKind:DropDown = toolbox.findComponent("toolboxNotesNoteKind", DropDown);
+    var toolboxNotesCustomKindLabel:Label = toolbox.findComponent("toolboxNotesCustomKindLabel", Label);
+    var toolboxNotesCustomKind:TextField = toolbox.findComponent("toolboxNotesCustomKind", TextField);
+
+    toolboxNotesNoteKind.onChange = (event:UIEvent) ->
+    {
+      var isCustom = (event.data.id == '~CUSTOM~');
+
+      if (isCustom)
+      {
+        toolboxNotesCustomKindLabel.hidden = false;
+        toolboxNotesCustomKind.hidden = false;
+
+        state.selectedNoteKind = toolboxNotesCustomKind.text;
+      }
+      else
+      {
+        toolboxNotesCustomKindLabel.hidden = true;
+        toolboxNotesCustomKind.hidden = true;
+
+        state.selectedNoteKind = event.data.id;
+      }
+    }
+
+    toolboxNotesCustomKind.onChange = (event:UIEvent) ->
+    {
+      state.selectedNoteKind = toolboxNotesCustomKind.text;
+    }
+
+    return toolbox;
+  }
+
+  static function buildToolboxEventDataLayout(state:ChartEditorState):Dialog
+  {
+    var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT);
+
+    if (toolbox == null)
+      return null;
+
+    // Starting position.
+    toolbox.x = 100;
+    toolbox.y = 150;
+
+    toolbox.onDialogClosed = (event:DialogEvent) ->
+    {
+      state.setUICheckboxSelected('menubarItemToggleToolboxEvents', false);
+    }
+
+    var toolboxEventsEventKind:DropDown = toolbox.findComponent("toolboxEventsEventKind", DropDown);
+    var toolboxEventsDataGrid:Grid = toolbox.findComponent("toolboxEventsDataGrid", Grid);
+
+    toolboxEventsEventKind.dataSource = new ArrayDataSource();
+
+    var songEvents:Array<SongEvent> = SongEventParser.listEvents();
+
+    for (event in songEvents)
+    {
+      toolboxEventsEventKind.dataSource.add({text: event.getTitle(), value: event.id});
+    }
+
+    toolboxEventsEventKind.onChange = (event:UIEvent) ->
+    {
+      var eventType:String = event.data.value;
+
+      trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event type changed: $eventType');
+
+      var schema:SongEventSchema = SongEventParser.getEventSchema(eventType);
+
+      if (schema == null)
+      {
+        trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Unknown event kind: $eventType');
+        return;
+      }
+
+      buildEventDataFormFromSchema(state, toolboxEventsDataGrid, schema);
+    }
+
+    return toolbox;
+  }
+
+  static function buildEventDataFormFromSchema(state:ChartEditorState, target:Box, schema:SongEventSchema):Void
+  {
+    trace(schema);
+    // Clear the frame.
+    target.removeAllComponents();
+
+    state.selectedEventData = {};
+
+    for (field in schema)
+    {
+      // Add a label.
+      var label:Label = new Label();
+      label.text = field.title;
+      target.addComponent(label);
+
+      var input:Component;
+      switch (field.type)
+      {
+        case INTEGER:
+          var numberStepper:NumberStepper = new NumberStepper();
+          numberStepper.id = field.name;
+          numberStepper.step = field.step == null ? 1.0 : field.step;
+          numberStepper.min = field.min;
+          numberStepper.max = field.max;
+          numberStepper.value = field.defaultValue;
+          input = numberStepper;
+        case FLOAT:
+          var numberStepper:NumberStepper = new NumberStepper();
+          numberStepper.id = field.name;
+          numberStepper.step = field.step == null ? 0.1 : field.step;
+          numberStepper.min = field.min;
+          numberStepper.max = field.max;
+          numberStepper.value = field.defaultValue;
+          input = numberStepper;
+        case BOOL:
+          var checkBox = new CheckBox();
+          checkBox.id = field.name;
+          checkBox.selected = field.defaultValue == true;
+          input = checkBox;
+        case ENUM:
+          var dropDown:DropDown = new DropDown();
+          dropDown.id = field.name;
+          dropDown.dataSource = new ArrayDataSource();
+
+          // Add entries to the dropdown.
+          for (optionName in field.keys.keys())
+          {
+            var optionValue = field.keys.get(optionName);
+            trace('$optionName : $optionValue');
+            dropDown.dataSource.add({value: optionValue, text: optionName});
+          }
+
+          dropDown.value = field.defaultValue;
+
+          input = dropDown;
+        case STRING:
+          input = new TextField();
+          input.id = field.name;
+          input.text = field.defaultValue;
+        default:
+          // Unknown type. Display a label so we know what it is.
+          input = new Label();
+          input.id = field.name;
+          input.text = field.type;
+      }
+
+      target.addComponent(input);
+
+      input.onChange = (event:UIEvent) ->
+      {
+        trace('ChartEditorToolboxHandler.buildEventDataFormFromSchema() - ${event.target.id} = ${event.target.value}');
+
+        if (event.target.value == null)
+          state.selectedEventData.remove(event.target.id);
+        else
+          state.selectedEventData.set(event.target.id, event.target.value);
+      }
+    }
+  }
+
+  static function buildToolboxDifficultyLayout(state:ChartEditorState):Dialog
+  {
+    var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+
+    if (toolbox == null)
+      return null;
+
+    // Starting position.
+    toolbox.x = 125;
+    toolbox.y = 200;
+
+    toolbox.onDialogClosed = (event:DialogEvent) ->
+    {
+      state.setUICheckboxSelected('menubarItemToggleToolboxDifficulty', false);
+    }
+
+    var difficultyToolboxSaveMetadata:Button = toolbox.findComponent("difficultyToolboxSaveMetadata", Button);
+    var difficultyToolboxSaveChart:Button = toolbox.findComponent("difficultyToolboxSaveChart", Button);
+    var difficultyToolboxSaveAll:Button = toolbox.findComponent("difficultyToolboxSaveAll", Button);
+    var difficultyToolboxLoadMetadata:Button = toolbox.findComponent("difficultyToolboxLoadMetadata", Button);
+    var difficultyToolboxLoadChart:Button = toolbox.findComponent("difficultyToolboxLoadChart", Button);
+
+    difficultyToolboxSaveMetadata.onClick = (event:UIEvent) ->
+    {
+      SongSerializer.exportSongMetadata(state.currentSongMetadata);
+    };
+
+    difficultyToolboxSaveChart.onClick = (event:UIEvent) ->
+    {
+      SongSerializer.exportSongChartData(state.currentSongChartData);
+    };
+
+    difficultyToolboxSaveAll.onClick = (event:UIEvent) ->
+    {
+      state.exportAllSongData();
+    };
+
+    difficultyToolboxLoadMetadata.onClick = (event:UIEvent) ->
+    {
+      // Replace metadata for current variation.
+      SongSerializer.importSongMetadataAsync(function(songMetadata)
+      {
+        state.currentSongMetadata = songMetadata;
+      });
+    };
+
+    difficultyToolboxLoadChart.onClick = (event:UIEvent) ->
+    {
+      // Replace chart data for current variation.
+      SongSerializer.importSongChartDataAsync(function(songChartData)
+      {
+        state.currentSongChartData = songChartData;
+        state.noteDisplayDirty = true;
+      });
+    };
+
+    state.difficultySelectDirty = true;
+
+    return toolbox;
+  }
+
+  static function buildToolboxMetadataLayout(state:ChartEditorState):Dialog
+  {
+    var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
+
+    if (toolbox == null)
+      return null;
+
+    // Starting position.
+    toolbox.x = 150;
+    toolbox.y = 250;
+
+    toolbox.onDialogClosed = (event:DialogEvent) ->
+    {
+      state.setUICheckboxSelected('menubarItemToggleToolboxMetadata', false);
+    }
+
+    var inputSongName:TextField = toolbox.findComponent('inputSongName', TextField);
+    inputSongName.onChange = (event:UIEvent) ->
+    {
+      var valid = event.target.text != null && event.target.text != "";
+
+      if (valid)
+      {
+        inputSongName.removeClass('invalid-value');
+        state.currentSongMetadata.songName = event.target.text;
+      }
+      else
+      {
+        state.currentSongMetadata.songName = null;
+      }
+    };
+
+    var inputSongArtist:TextField = toolbox.findComponent('inputSongArtist', TextField);
+    inputSongArtist.onChange = (event:UIEvent) ->
+    {
+      var valid = event.target.text != null && event.target.text != "";
+
+      if (valid)
+      {
+        inputSongArtist.removeClass('invalid-value');
+        state.currentSongMetadata.artist = event.target.text;
+      }
+      else
+      {
+        state.currentSongMetadata.artist = null;
+      }
+    };
+
+    var inputStage:DropDown = toolbox.findComponent('inputStage', DropDown);
+    inputStage.onChange = (event:UIEvent) ->
+    {
+      var valid = event.data != null && event.data.id != null;
+
+      if (valid)
+      {
+        state.currentSongMetadata.playData.stage = event.data.id;
+      }
+    };
+
+    var inputNoteSkin:DropDown = toolbox.findComponent('inputNoteSkin', DropDown);
+    inputNoteSkin.onChange = (event:UIEvent) ->
+    {
+      if (event.data.id == null)
+        return;
+      state.currentSongMetadata.playData.noteSkin = event.data.id;
+    };
+
+    var inputBPM:NumberStepper = toolbox.findComponent('inputBPM', NumberStepper);
+    inputBPM.onChange = (event:UIEvent) ->
+    {
+      if (event.value == null || event.value <= 0)
+        return;
+
+      var timeChanges = state.currentSongMetadata.timeChanges;
+      if (timeChanges == null || timeChanges.length == 0)
+      {
+        timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])];
+      }
+      else
+      {
+        timeChanges[0].bpm = event.value;
+      }
+
+      Conductor.forceBPM(event.value);
+
+      state.currentSongMetadata.timeChanges = timeChanges;
+    };
+
+    var inputScrollSpeed:Slider = toolbox.findComponent('inputScrollSpeed', Slider);
+    inputScrollSpeed.onChange = (event:UIEvent) ->
+    {
+      var valid = event.target.value != null && event.target.value > 0;
+
+      if (valid)
+      {
+        inputScrollSpeed.removeClass('invalid-value');
+        state.currentSongChartData.scrollSpeed = event.target.value;
+      }
+      else
+      {
+        state.currentSongChartData.scrollSpeed = null;
+      }
+    };
+
+    return toolbox;
+  }
+
+  static function buildToolboxCharactersLayout(state:ChartEditorState):Dialog
+  {
+    var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT);
+
+    if (toolbox == null)
+      return null;
+
+    // Starting position.
+    toolbox.x = 175;
+    toolbox.y = 300;
+
+    toolbox.onDialogClosed = (event:DialogEvent) ->
+    {
+      state.setUICheckboxSelected('menubarItemToggleToolboxCharacters', false);
+    }
+
+    return toolbox;
+  }
+
+  static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Dialog
+  {
+    var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
+
+    if (toolbox == null)
+      return null;
+
+    // Starting position.
+    toolbox.x = 200;
+    toolbox.y = 350;
+
+    toolbox.onDialogClosed = (event:DialogEvent) ->
+    {
+      state.setUICheckboxSelected('menubarItemToggleToolboxPlayerPreview', false);
+    }
+
+    var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer');
+    // TODO: We need to implement character swapping in ChartEditorState.
+    charPlayer.loadCharacter('bf');
+    // charPlayer.setScale(0.5);
+    charPlayer.setCharacterType(CharacterType.BF);
+    charPlayer.flip = true;
+
+    return toolbox;
+  }
+
+  static function buildToolboxOpponentPreviewLayout(state:ChartEditorState):Dialog
+  {
+    var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
+
+    if (toolbox == null)
+      return null;
+
+    // Starting position.
+    toolbox.x = 200;
+    toolbox.y = 350;
+
+    toolbox.onDialogClosed = (event:DialogEvent) ->
+    {
+      state.setUICheckboxSelected('menubarItemToggleToolboxOpponentPreview', false);
+    }
+
+    var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer');
+    // TODO: We need to implement character swapping in ChartEditorState.
+    charPlayer.loadCharacter('dad');
+    // charPlayer.setScale(0.5);
+    charPlayer.setCharacterType(CharacterType.DAD);
+    charPlayer.flip = false;
+
+    return toolbox;
+  }
 }
diff --git a/source/funkin/ui/haxeui/components/Notifbar.hx b/source/funkin/ui/haxeui/components/Notifbar.hx
new file mode 100644
index 000000000..b643d360b
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/Notifbar.hx
@@ -0,0 +1,113 @@
+package funkin.ui.haxeui.components;
+
+import flixel.FlxG;
+import flixel.util.FlxTimer;
+import haxe.ui.RuntimeComponentBuilder;
+import haxe.ui.components.Button;
+import haxe.ui.components.Label;
+import haxe.ui.containers.Box;
+import haxe.ui.containers.SideBar;
+import haxe.ui.containers.VBox;
+import haxe.ui.core.Component;
+
+class Notifbar extends SideBar
+{
+	final NOTIFICATION_DISMISS_TIME = 5.0; // seconds
+	var dismissTimer:FlxTimer = null;
+
+	var outerContainer:Box = null;
+	var container:VBox = null;
+	var message:Label = null;
+	var action:Button = null;
+	var dismiss:Button = null;
+
+	public function new()
+	{
+		super();
+
+		buildSidebar();
+		buildChildren();
+	}
+
+	public function showNotification(message:String, ?actionText:String = null, ?actionCallback:Void->Void = null, ?dismissTime:Float = null)
+	{
+		if (dismissTimer != null)
+			dismissNotification();
+
+		if (dismissTime == null)
+			dismissTime = NOTIFICATION_DISMISS_TIME;
+
+		// Message text.
+		this.message.text = message;
+
+		// Action
+		if (actionText != null)
+		{
+			this.action.text = actionText;
+			this.action.visible = true;
+			this.action.disabled = false;
+			this.action.onClick = (_) ->
+			{
+				actionCallback();
+			};
+		}
+		else
+		{
+			this.action.visible = false;
+			this.action.disabled = false;
+			this.action.onClick = null;
+		}
+
+		this.show();
+
+		// Auto dismiss.
+		dismissTimer = new FlxTimer().start(dismissTime, (_:FlxTimer) -> dismissNotification());
+	}
+
+	public function dismissNotification()
+	{
+		if (dismissTimer != null)
+		{
+			dismissTimer.cancel();
+			dismissTimer = null;
+		}
+
+		this.hide();
+	}
+
+	function buildSidebar():Void
+	{
+		this.width = 256;
+		this.height = 80;
+
+		// border-top: 1px solid #000; border-left: 1px solid #000;
+		this.styleString = "border: 1px solid #000; background-color: #3d3f41; padding: 8px; border-top-left-radius: 8px;";
+
+		// float to the right
+		this.x = FlxG.width - this.width;
+
+		this.position = "bottom";
+		this.method = "float";
+	}
+
+	function buildChildren():Void
+	{
+		outerContainer = cast(buildComponent("assets/data/notifbar.xml"), Box);
+		addComponent(outerContainer);
+
+		container = outerContainer.findComponent('notifbarContainer', VBox);
+		message = outerContainer.findComponent('notifbarMessage', Label);
+		action = outerContainer.findComponent('notifbarAction', Button);
+		dismiss = outerContainer.findComponent('notifbarDismiss', Button);
+
+		dismiss.onClick = (_) ->
+		{
+			dismissNotification();
+		};
+	}
+
+	function buildComponent(path:String):Component
+	{
+		return RuntimeComponentBuilder.fromAsset(path);
+	}
+}
diff --git a/source/funkin/ui/stageBuildShit/StageOffsetSubstate.hx b/source/funkin/ui/stageBuildShit/StageOffsetSubstate.hx
index a7c8cff5f..8c2419de5 100644
--- a/source/funkin/ui/stageBuildShit/StageOffsetSubstate.hx
+++ b/source/funkin/ui/stageBuildShit/StageOffsetSubstate.hx
@@ -2,21 +2,15 @@ package funkin.ui.stageBuildShit;
 
 import flixel.FlxSprite;
 import flixel.input.mouse.FlxMouseEvent;
-<<<<<<< HEAD
 import flixel.input.mouse.FlxMouseEventManager;
-=======
->>>>>>> origin/feature/week-4-gameplay
 import flixel.math.FlxPoint;
 import funkin.play.PlayState;
-<<<<<<< HEAD
 import funkin.play.stage.StageData.StageDataParser;
 import funkin.play.stage.StageData;
 import haxe.ui.RuntimeComponentBuilder;
 import haxe.ui.containers.VBox;
-=======
 import funkin.play.stage.StageData;
 import haxe.ui.RuntimeComponentBuilder;
->>>>>>> origin/feature/week-4-gameplay
 import haxe.ui.core.Component;
 import openfl.events.Event;
 import openfl.events.IOErrorEvent;
diff --git a/source/funkin/util/WindowUtil.hx b/source/funkin/util/WindowUtil.hx
index bf80e688f..c566d154c 100644
--- a/source/funkin/util/WindowUtil.hx
+++ b/source/funkin/util/WindowUtil.hx
@@ -2,35 +2,54 @@ package funkin.util;
 
 import flixel.util.FlxSignal.FlxTypedSignal;
 
+#if cpp
+@:cppFileCode('
+#include <iostream>
+#include <windows.h>
+#include <psapi.h>
+')
+#end
 class WindowUtil
 {
-	public static function openURL(targetUrl:String)
-	{
-		#if CAN_OPEN_LINKS
-		#if linux
-		// Sys.command('/usr/bin/xdg-open', [, "&"]);
-		Sys.command('/usr/bin/xdg-open', [targetUrl, "&"]);
-		#else
-		FlxG.openURL(targetUrl);
-		#end
-		#else
-		trace('Cannot open');
-		#end
-	}
+  public static function openURL(targetUrl:String)
+  {
+    #if CAN_OPEN_LINKS
+    #if linux
+    // Sys.command('/usr/bin/xdg-open', [, "&"]);
+    Sys.command('/usr/bin/xdg-open', [targetUrl, "&"]);
+    #else
+    FlxG.openURL(targetUrl);
+    #end
+    #else
+    trace('Cannot open');
+    #end
+  }
 
-	/**
-	 * Dispatched when the game window is closed.
-	 */
-	public static final windowExit:FlxTypedSignal<Int->Void> = new FlxTypedSignal<Int->Void>();
+  /**
+   * Dispatched when the game window is closed.
+   */
+  public static final windowExit:FlxTypedSignal<Int->Void> = new FlxTypedSignal<Int->Void>();
 
-	public static function initWindowEvents()
-	{
-		// onUpdate is called every frame just before rendering.
+  public static function initWindowEvents()
+  {
+    // onUpdate is called every frame just before rendering.
 
-		// onExit is called when the game window is closed.
-		openfl.Lib.current.stage.application.onExit.add(function(exitCode:Int)
-		{
-			windowExit.dispatch(exitCode);
-		});
-	}
+    // onExit is called when the game window is closed.
+    openfl.Lib.current.stage.application.onExit.add(function(exitCode:Int)
+    {
+      windowExit.dispatch(exitCode);
+    });
+  }
+
+  /**
+   * Turns off that annoying "Report to Microsoft" dialog that pops up when the game crashes.
+   */
+  public static function disableCrashHandler()
+  {
+    #if cpp
+    untyped __cpp__('SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX);');
+    #else
+    // Do nothing.
+    #end
+  }
 }
diff --git a/source/funkin/util/assets/DataAssets.hx b/source/funkin/util/assets/DataAssets.hx
index 3e95e92c3..afa041d5b 100644
--- a/source/funkin/util/assets/DataAssets.hx
+++ b/source/funkin/util/assets/DataAssets.hx
@@ -4,7 +4,7 @@ class DataAssets
 {
 	static function buildDataPath(path:String):String
 	{
-		return 'default:assets/data/${path}';
+		return 'assets/data/${path}';
 	}
 
 	public static function listDataFilesInPath(path:String, ?suffix:String = '.json'):Array<String>
diff --git a/source/funkin/util/macro/ClassMacro.hx b/source/funkin/util/macro/ClassMacro.hx
new file mode 100644
index 000000000..4589eaac8
--- /dev/null
+++ b/source/funkin/util/macro/ClassMacro.hx
@@ -0,0 +1,204 @@
+package funkin.util.macro;
+
+import haxe.macro.Context;
+import haxe.macro.Expr;
+import haxe.macro.Type;
+import funkin.util.macro.MacroUtil;
+
+/**
+ * Macros to generate lists of classes at compile time.
+ * 
+ * This code is a bitch glad Jason figured it out.
+ * Based on code from CompileTime: https://github.com/jasononeil/compiletime
+ */
+class ClassMacro
+{
+  /**
+   * Gets a list of `Class<T>` for all classes in a specified package.
+   * 
+   * Example: `var list:Array<Class<Dynamic>> = listClassesInPackage("funkin", true);`
+   * 
+   * @param targetPackage A String containing the package name to query.
+   * @param includeSubPackages Whether to include classes located in sub-packages of the target package.
+   * @return A list of classes matching the specified criteria.
+   */
+  public static macro function listClassesInPackage(targetPackage:String, ?includeSubPackages:Bool = true):ExprOf<Iterable<Class<Dynamic>>>
+  {
+    if (!onGenerateCallbackRegistered)
+    {
+      onGenerateCallbackRegistered = true;
+      Context.onGenerate(onGenerate);
+    }
+
+    var request:String = 'package~${targetPackage}~${includeSubPackages ? "recursive" : "nonrecursive"}';
+
+    classListsToGenerate.push(request);
+
+    return macro funkin.util.macro.CompiledClassList.get($v{request});
+  }
+
+  /**
+   * Get a list of `Class<T>` for all classes extending a specified class.
+   * 
+   * Example: `var list:Array<Class<FlxSprite>> = listSubclassesOf(FlxSprite);`
+   * 
+   * @param targetClass The class to query for subclasses.
+   * @return A list of classes matching the specified criteria.
+   */
+  public static macro function listSubclassesOf<T>(targetClassExpr:ExprOf<Class<T>>):ExprOf<List<Class<T>>>
+  {
+    if (!onGenerateCallbackRegistered)
+    {
+      onGenerateCallbackRegistered = true;
+      Context.onGenerate(onGenerate);
+    }
+
+    var targetClass:ClassType = MacroUtil.getClassTypeFromExpr(targetClassExpr);
+    var targetClassPath:String = null;
+    if (targetClass != null)
+      targetClassPath = targetClass.pack.join('.') + '.' + targetClass.name;
+
+    var request:String = 'extend~${targetClassPath}';
+
+    classListsToGenerate.push(request);
+
+    return macro funkin.util.macro.CompiledClassList.getTyped($v{request}, ${targetClassExpr});
+  }
+
+  #if macro
+  /**
+   * Callback executed after the typing phase but before the generation phase.
+   * Receives a list of `haxe.macro.Type` for all types in the program.
+   * 
+   * Only metadata can be modified at this time, which makes it a BITCH to access the data at runtime.
+   */
+  static function onGenerate(allTypes:Array<haxe.macro.Type>)
+  {
+    // Reset these, since onGenerate persists across multiple builds.
+    classListsRaw = [];
+
+    for (request in classListsToGenerate)
+    {
+      classListsRaw.set(request, []);
+    }
+
+    for (type in allTypes)
+    {
+      switch (type)
+      {
+        // Class instances
+        case TInst(t, _params):
+          var classType:ClassType = t.get();
+          var className:String = t.toString();
+
+          if (classType.isInterface)
+          {
+            // Ignore interfaces.
+          }
+          else
+          {
+            for (request in classListsToGenerate)
+            {
+              if (doesClassMatchRequest(classType, request))
+              {
+                classListsRaw.get(request).push(className);
+              }
+            }
+          }
+        // Other types (things like enums)
+        default:
+          continue;
+      }
+    }
+
+    compileClassLists();
+  }
+
+  /**
+   * At this stage in the program, `classListsRaw` is generated, but only accessible by macros.
+   * To make it accessible at runtime, we must:
+   * - Convert the String names to actual `Class<T>` instances, and store it as `classLists`
+   * - Insert the `classLists` into the metadata of the `CompiledClassList` class.
+   * `CompiledClassList` then extracts the metadata and stores it where it can be accessed at runtime.
+   */
+  static function compileClassLists()
+  {
+    var compiledClassList:ClassType = MacroUtil.getClassType("funkin.util.macro.CompiledClassList");
+
+    if (compiledClassList == null)
+      throw "Could not find CompiledClassList class.";
+
+    // Reset outdated metadata.
+    if (compiledClassList.meta.has('classLists'))
+      compiledClassList.meta.remove('classLists');
+
+    var classLists:Array<Expr> = [];
+    // Generate classLists.
+    for (request in classListsToGenerate)
+    {
+      // Expression contains String, [Class<T>...]
+      var classListEntries:Array<Expr> = [macro $v{request}];
+      for (i in classListsRaw.get(request))
+      {
+        // TODO: Boost performance by making this an Array<Class<T>> instead of an Array<String>
+        // How to perform perform macro reificiation to types given a name?
+        classListEntries.push(macro $v{i});
+      }
+
+      classLists.push(macro $a{classListEntries});
+    }
+
+    // Insert classLists into metadata.
+    compiledClassList.meta.add('classLists', classLists, Context.currentPos());
+  }
+
+  static function doesClassMatchRequest(classType:ClassType, request:String):Bool
+  {
+    var splitRequest:Array<String> = request.split('~');
+
+    var requestType:String = splitRequest[0];
+
+    switch (requestType)
+    {
+      case 'package':
+        var targetPackage:String = splitRequest[1];
+        var recursive:Bool = splitRequest[2] == 'recursive';
+
+        var classPackage:String = classType.pack.join('.');
+
+        if (recursive)
+        {
+          return StringTools.startsWith(classPackage, targetPackage);
+        }
+        else
+        {
+          var regex:EReg = ~/^${targetPackage}(\.|$)/;
+          return regex.match(classPackage);
+        }
+      case 'extend':
+        var targetClassName:String = splitRequest[1];
+
+        var targetClassType:ClassType = MacroUtil.getClassType(targetClassName);
+
+        if (MacroUtil.implementsInterface(classType, targetClassType))
+        {
+          return true;
+        }
+        else if (MacroUtil.isSubclassOf(classType, targetClassType))
+        {
+          return true;
+        }
+
+        return false;
+
+      default:
+        throw 'Unknown request type: ${requestType}';
+    }
+  }
+
+  static var onGenerateCallbackRegistered:Bool = false;
+
+  static var classListsRaw:Map<String, Array<String>> = [];
+  static var classListsToGenerate:Array<String> = [];
+  #end
+}
diff --git a/source/funkin/util/macro/CompiledClassList.hx b/source/funkin/util/macro/CompiledClassList.hx
new file mode 100644
index 000000000..d7a50668e
--- /dev/null
+++ b/source/funkin/util/macro/CompiledClassList.hx
@@ -0,0 +1,69 @@
+package funkin.util.macro;
+
+import haxe.rtti.Meta;
+
+/**
+ * A complement to `ClassMacro`. See `ClassMacro` for more information.
+ */
+class CompiledClassList
+{
+  static var classLists:Map<String, List<Class<Dynamic>>>;
+
+  /**
+   * Class lists are injected into this class's metadata during the typing phase.
+   * This function extracts the metadata, at runtime, and stores it in `classLists`.
+   */
+  static function init():Void
+  {
+    classLists = [];
+
+    // Meta.getType returns Dynamic<Array<Dynamic>>.
+    var metaData = Meta.getType(CompiledClassList);
+
+    if (metaData.classLists != null)
+    {
+      for (list in metaData.classLists)
+      {
+        var data:Array<Dynamic> = cast list;
+
+        // First element is the list ID.
+        var id:String = cast data[0];
+
+        // All other elements are class types.
+        var classes:List<Class<Dynamic>> = new List();
+        for (i in 1...data.length)
+        {
+          var className:String = cast data[i];
+          // var classType:Class<Dynamic> = cast data[i];
+          var classType:Class<Dynamic> = cast Type.resolveClass(className);
+          classes.push(classType);
+        }
+
+        classLists.set(id, classes);
+      }
+    }
+    else
+    {
+      throw "Class lists not properly generated. Try cleaning out your export folder, restarting your IDE, and rebuilding your project.";
+    }
+  }
+
+  public static function get(request:String):List<Class<Dynamic>>
+  {
+    if (classLists == null)
+      init();
+
+    if (!classLists.exists(request))
+    {
+      trace('[WARNING] Class list $request not properly generated. Please debug the build macro.');
+      classLists.set(request, new List()); // Make the error only appear once.
+    }
+
+    return classLists.get(request);
+  }
+
+  public static inline function getTyped<T>(request:String, type:Class<T>):List<Class<T>>
+  {
+    return cast get(request);
+  }
+}
diff --git a/source/funkin/util/macro/GitCommit.hx b/source/funkin/util/macro/GitCommit.hx
index a9b6a6b65..0449857cd 100644
--- a/source/funkin/util/macro/GitCommit.hx
+++ b/source/funkin/util/macro/GitCommit.hx
@@ -3,59 +3,65 @@ package funkin.util.macro;
 #if debug
 class GitCommit
 {
-	public static macro function getGitCommitHash():haxe.macro.Expr.ExprOf<String>
-	{
-		#if !display
-		// Get the current line number.
-		var pos = haxe.macro.Context.currentPos();
+  /**
+   * Get the SHA1 hash of the current Git commit.
+   */
+  public static macro function getGitCommitHash():haxe.macro.Expr.ExprOf<String>
+  {
+    #if !display
+    // Get the current line number.
+    var pos = haxe.macro.Context.currentPos();
 
-		var process = new sys.io.Process('git', ['rev-parse', 'HEAD']);
-		if (process.exitCode() != 0)
-		{
-			var message = process.stderr.readAll().toString();
-			haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos);
-		}
+    var process = new sys.io.Process('git', ['rev-parse', 'HEAD']);
+    if (process.exitCode() != 0)
+    {
+      var message = process.stderr.readAll().toString();
+      haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos);
+    }
 
-		// read the output of the process
-		var commitHash:String = process.stdout.readLine();
-		var commitHashSplice:String = commitHash.substr(0, 7);
+    // read the output of the process
+    var commitHash:String = process.stdout.readLine();
+    var commitHashSplice:String = commitHash.substr(0, 7);
 
-		trace('Git Commit ID: ${commitHashSplice}');
+    trace('Git Commit ID: ${commitHashSplice}');
 
-		// Generates a string expression
-		return macro $v{commitHashSplice};
-		#else
-		// `#if display` is used for code completion. In this case returning an
-		// empty string is good enough; We don't want to call git on every hint.
-		var commitHash:String = "";
-		return macro $v{commitHashSplice};
-		#end
-	}
+    // Generates a string expression
+    return macro $v{commitHashSplice};
+    #else
+    // `#if display` is used for code completion. In this case returning an
+    // empty string is good enough; We don't want to call git on every hint.
+    var commitHash:String = "";
+    return macro $v{commitHashSplice};
+    #end
+  }
 
-	public static macro function getGitBranch():haxe.macro.Expr.ExprOf<String>
-	{
-		#if !display
-		// Get the current line number.
-		var pos = haxe.macro.Context.currentPos();
-		var branchProcess = new sys.io.Process('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
+  /**
+   * Get the branch name of the current Git commit.
+   */
+  public static macro function getGitBranch():haxe.macro.Expr.ExprOf<String>
+  {
+    #if !display
+    // Get the current line number.
+    var pos = haxe.macro.Context.currentPos();
+    var branchProcess = new sys.io.Process('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
 
-		if (branchProcess.exitCode() != 0)
-		{
-			var message = branchProcess.stderr.readAll().toString();
-			haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos);
-		}
+    if (branchProcess.exitCode() != 0)
+    {
+      var message = branchProcess.stderr.readAll().toString();
+      haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos);
+    }
 
-		var branchName:String = branchProcess.stdout.readLine();
-		trace('Git Branch Name: ${branchName}');
+    var branchName:String = branchProcess.stdout.readLine();
+    trace('Git Branch Name: ${branchName}');
 
-		// Generates a string expression
-		return macro $v{branchName};
-		#else
-		// `#if display` is used for code completion. In this case returning an
-		// empty string is good enough; We don't want to call git on every hint.
-		var branchName:String = "";
-		return macro $v{branchName};
-		#end
-	}
+    // Generates a string expression
+    return macro $v{branchName};
+    #else
+    // `#if display` is used for code completion. In this case returning an
+    // empty string is good enough; We don't want to call git on every hint.
+    var branchName:String = "";
+    return macro $v{branchName};
+    #end
+  }
 }
 #end
diff --git a/source/funkin/util/macro/MacroUtil.hx b/source/funkin/util/macro/MacroUtil.hx
index ebf5bd5ec..1edf07e8f 100644
--- a/source/funkin/util/macro/MacroUtil.hx
+++ b/source/funkin/util/macro/MacroUtil.hx
@@ -1,12 +1,173 @@
 package funkin.util.macro;
 
+import haxe.macro.Context;
+import haxe.macro.Expr;
+import haxe.macro.Type;
+
 class MacroUtil
 {
-	public static macro function getDefine(key:String, defaultValue:String = null):haxe.macro.Expr
-	{
-		var value = haxe.macro.Context.definedValue(key);
-		if (value == null)
-			value = defaultValue;
-		return macro $v{value};
-	}
+  /**
+   * Gets the value of a Haxe compiler define.
+   * 
+   * @param key The name of the define to get the value of.
+   * @param defaultValue The value to return if the define is not set.
+   * @return An expression containing the value of the define.
+   */
+  public static macro function getDefine(key:String, defaultValue:String = null):haxe.macro.Expr
+  {
+    var value = haxe.macro.Context.definedValue(key);
+    if (value == null)
+      value = defaultValue;
+    return macro $v{value};
+  }
+
+  /**
+   * Gets the current date and time (at compile time).
+   * @return A `Date` object containing the current date and time.
+   */
+  public static macro function getDate():ExprOf<Date>
+  {
+    var date = Date.now();
+    var year = toExpr(date.getFullYear());
+    var month = toExpr(date.getMonth());
+    var day = toExpr(date.getDate());
+    var hours = toExpr(date.getHours());
+    var mins = toExpr(date.getMinutes());
+    var secs = toExpr(date.getSeconds());
+    return macro new Date($year, $month, $day, $hours, $mins, $secs);
+  }
+
+  #if macro
+  //
+  // MACRO HELPER FUNCTIONS
+  //
+
+  /**
+   * Convert an ExprOf<Class<T>> to a ClassType.
+   * @see https://github.com/jasononeil/compiletime/blob/master/src/CompileTime.hx#L201
+   */
+  public static function getClassTypeFromExpr(e:Expr):ClassType
+  {
+    var classType:ClassType = null;
+
+    var parts:Array<String> = [];
+    var nextSection:ExprDef = e.expr;
+
+    while (nextSection != null)
+    {
+      var section:ExprDef = nextSection;
+      nextSection = null;
+
+      switch (section)
+      {
+        // Expression is a class name with no packages
+        case EConst(c):
+          switch (c)
+          {
+            case CIdent(cn):
+              if (cn != "null") parts.unshift(cn);
+            default:
+          }
+        // Expression is a fully qualified package name.
+        // We need to traverse the expression tree to get the full package name.
+        case EField(exp, field):
+          nextSection = exp.expr;
+          parts.unshift(field);
+
+        // We've reached the end of the expression tree.
+        default:
+      }
+    }
+
+    var fullClassName:String = parts.join('.');
+    if (fullClassName != "")
+    {
+      var classType:Type = Context.getType(fullClassName);
+      // Follow typedefs to get the actual class type.
+      var classTypeParsed:Type = Context.follow(classType, false);
+
+      switch (classTypeParsed)
+      {
+        case TInst(t, params):
+          return t.get();
+        default:
+          // We couldn't parse this class type.
+          // This function may need to be updated to be more robust.
+          throw 'Class type could not be parsed: ${fullClassName}';
+      }
+    }
+
+    return null;
+  }
+
+  /**
+   * Converts a value to an equivalent macro expression.
+   */
+  public static function toExpr(value:Dynamic):ExprOf<Dynamic>
+  {
+    return Context.makeExpr(value, Context.currentPos());
+  }
+
+  public static function areClassesEqual(class1:ClassType, class2:ClassType):Bool
+  {
+    return class1.pack.join('.') == class2.pack.join('.') && class1.name == class2.name;
+  }
+
+  /**
+   * Retrieve a ClassType from a string name.
+   */
+  public static function getClassType(name:String):ClassType
+  {
+    switch (Context.getType(name))
+    {
+      case TInst(t, _params):
+        return t.get();
+      default:
+        throw 'Class type could not be parsed: ${name}';
+    }
+  }
+
+  /**
+   * Determine whether a given ClassType is a subclass of a given superclass.
+   * @param classType The class to check.
+   * @param superClass The superclass to check for.
+   * @return Whether the class is a subclass of the superclass.
+   */
+  public static function isSubclassOf(classType:ClassType, superClass:ClassType):Bool
+  {
+    if (areClassesEqual(classType, superClass))
+      return true;
+
+    if (classType.superClass != null)
+    {
+      return isSubclassOf(classType.superClass.t.get(), superClass);
+    }
+
+    return false;
+  }
+
+  /**
+   * Determine whether a given ClassType implements a given interface.
+   * @param classType The class to check.
+   * @param interfaceType The interface to check for.
+   * @return Whether the class implements the interface.
+   */
+  public static function implementsInterface(classType:ClassType, interfaceType:ClassType):Bool
+  {
+    for (i in classType.interfaces)
+    {
+      if (areClassesEqual(i.t.get(), interfaceType))
+      {
+        return true;
+      }
+    }
+
+    if (classType.superClass != null)
+    {
+      return implementsInterface(classType.superClass.t.get(), interfaceType);
+    }
+
+    return false;
+  }
+  #end
 }
diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx
new file mode 100644
index 000000000..296a818c7
--- /dev/null
+++ b/source/funkin/util/tools/MapTools.hx
@@ -0,0 +1,16 @@
+package funkin.util.tools;
+
+/**
+ * A static extension which provides utility functions for Maps.
+ * 
+ * For example, add `using MapTools` then call `map.values()`.
+ * 
+ * @see https://haxe.org/manual/lf-static-extension.html
+ */
+class MapTools
+{
+  public static function values<K, T>(map:Map<K, T>):Array<T>
+  {
+    return [for (i in map.iterator()) i];
+  }
+}