diff --git a/Project.xml b/Project.xml
index 1382f5f71..4c6ad39a6 100644
--- a/Project.xml
+++ b/Project.xml
@@ -122,7 +122,8 @@
 	<!--haxelib name="newgrounds" unless="switch"/> -->
 	<haxelib name="faxe" if='switch' />
 	<haxelib name="polymod" />
-	<haxelib name="firetongue" />
+
+	<haxelib name="thx.semver" />
 
 	<!-- <haxelib name="colyseus"/> -->
 	<!-- <haxelib name="colyseus-websocket" /> -->
diff --git a/example_mods/introMod/images/gfDanceTitle.png b/example_mods/introMod/images/gfDanceTitle.png
deleted file mode 100644
index 989f2a68a..000000000
Binary files a/example_mods/introMod/images/gfDanceTitle.png and /dev/null differ
diff --git a/source/Main.hx b/source/Main.hx
index fb2a01a69..32b7433f3 100644
--- a/source/Main.hx
+++ b/source/Main.hx
@@ -46,8 +46,6 @@ class Main extends Sprite
 		// 4. Replace the call to PolymodHandler.loadAllMods() with a call to PolymodHandler.loadModsById(ids:Array<String>).
 		funkin.modding.PolymodHandler.loadAllMods();
 
-		funkin.i18n.FireTongueHandler.init();
-
 		if (stage != null)
 		{
 			init();
diff --git a/source/funkin/Character.hx b/source/funkin/Character.hx
index a81d599a6..0ffac56e8 100644
--- a/source/funkin/Character.hx
+++ b/source/funkin/Character.hx
@@ -57,7 +57,6 @@ class Character extends FlxSprite
 				loadOffsetFile(curCharacter);
 
 				playAnim('danceRight');
-
 			case 'gf-christmas':
 				tex = Paths.getSparrowAtlas('characters/gfChristmas');
 				frames = tex;
@@ -133,19 +132,6 @@ class Character extends FlxSprite
 				updateHitbox();
 				antialiasing = false;
 
-			case 'dad':
-				// DAD ANIMATION LOADING CODE
-				tex = Paths.getSparrowAtlas('characters/DADDY_DEAREST');
-				frames = tex;
-				quickAnimAdd('idle', 'Dad idle dance');
-				quickAnimAdd('singUP', 'Dad Sing Note UP');
-				quickAnimAdd('singRIGHT', 'Dad Sing Note RIGHT');
-				quickAnimAdd('singDOWN', 'Dad Sing Note DOWN');
-				quickAnimAdd('singLEFT', 'Dad Sing Note LEFT');
-
-				loadOffsetFile(curCharacter);
-
-				playAnim('idle');
 			case 'spooky':
 				tex = Paths.getSparrowAtlas('characters/spooky_kids_assets');
 				frames = tex;
@@ -259,36 +245,6 @@ class Character extends FlxSprite
 
 				loadMappedAnims();
 
-			case 'bf':
-				var tex = Paths.getSparrowAtlas('characters/BOYFRIEND');
-				frames = tex;
-				quickAnimAdd('idle', 'BF idle dance');
-				quickAnimAdd('singUP', 'BF NOTE UP0');
-				quickAnimAdd('singLEFT', 'BF NOTE LEFT0');
-				quickAnimAdd('singRIGHT', 'BF NOTE RIGHT0');
-				quickAnimAdd('singDOWN', 'BF NOTE DOWN0');
-				quickAnimAdd('singUPmiss', 'BF NOTE UP MISS');
-				quickAnimAdd('singLEFTmiss', 'BF NOTE LEFT MISS');
-				quickAnimAdd('singRIGHTmiss', 'BF NOTE RIGHT MISS');
-				quickAnimAdd('singDOWNmiss', 'BF NOTE DOWN MISS');
-				quickAnimAdd('preAttack', 'bf pre attack');
-				quickAnimAdd('attack', 'boyfriend attack');
-				quickAnimAdd('hey', 'BF HEY');
-
-				quickAnimAdd('firstDeath', "BF dies");
-				animation.addByPrefix('deathLoop', "BF Dead Loop", 24, true);
-				quickAnimAdd('deathConfirm', "BF Dead confirm");
-
-				animation.addByPrefix('scared', 'BF idle shaking', 24, true);
-
-				loadOffsetFile(curCharacter);
-
-				playAnim('idle');
-
-				flipX = true;
-
-				loadOffsetFile(curCharacter);
-
 			case 'bf-christmas':
 				var tex = Paths.getSparrowAtlas('characters/bfChristmas');
 				frames = tex;
@@ -693,7 +649,7 @@ class Character extends FlxSprite
 	 */
 	public function dance()
 	{
-		if (animation == null)
+		if (animation == null || animation.curAnim == null)
 			return;
 		if (!debugMode)
 		{
diff --git a/source/funkin/GameOverSubstate.hx b/source/funkin/GameOverSubstate.hx
index 3c397ee78..fe957af25 100644
--- a/source/funkin/GameOverSubstate.hx
+++ b/source/funkin/GameOverSubstate.hx
@@ -1,5 +1,8 @@
 package funkin;
 
+import funkin.modding.events.ScriptEventDispatcher;
+import funkin.modding.events.ScriptEvent;
+import funkin.play.character.BaseCharacter;
 import flixel.FlxObject;
 import flixel.FlxSubState;
 import flixel.math.FlxPoint;
@@ -10,80 +13,83 @@ import haxe.display.Display;
 import funkin.ui.PreferencesMenu;
 import funkin.play.PlayState;
 
+using StringTools;
+
+/**
+ * A substate which renders over the PlayState when the player dies.
+ * Displays the player death animation, plays the music, and handles restarting the song.
+ * 
+ * The newest implementation uses a substate, which prevents having to reload the song and stage each reset.
+ */
 class GameOverSubstate extends MusicBeatSubstate
 {
-	var bf:Boyfriend;
-	var camFollow:FlxObject;
+	/**
+	 * The boyfriend character.
+	 */
+	var boyfriend:BaseCharacter;
 
-	var stageSuffix:String = "";
-	var randomGameover:Int = 1;
+	/**
+	 * The invisible object in the scene which the camera focuses on.
+	 */
+	var cameraFollowPoint:FlxObject;
 
-	var gameOverMusic:FlxSound;
+	/**
+	 * The music playing in the background of the state.
+	 */
+	var gameOverMusic:FlxSound = new FlxSound();
+
+	/**
+	 * Whether the player has confirmed and prepared to restart the level.
+	 * This means the animation and transition have already started.
+	 */
+	var isEnding:Bool = false;
+
+	/**
+	 * Music variant to use.
+	 * TODO: De-hardcode this somehow.
+	 */
+	var musicVariant:String = "";
 
 	public function new()
 	{
-		gameOverMusic = new FlxSound();
-		FlxG.sound.list.add(gameOverMusic);
-
-		var daStage = PlayState.instance.currentStageId;
-		var daBf:String = '';
-		switch (daStage)
-		{
-			case 'school' | 'schoolEvil':
-				stageSuffix = '-pixel';
-				daBf = 'bf-pixel-dead';
-			default:
-				daBf = 'bf';
-		}
-
-		var daSong = PlayState.currentSong.song.toLowerCase();
-
-		switch (daSong)
-		{
-			case 'stress':
-				daBf = 'bf-holding-gf-dead';
-		}
-
 		super();
 
+		FlxG.sound.list.add(gameOverMusic);
+		gameOverMusic.stop();
+
 		Conductor.songPosition = 0;
 
-		var bfXPos = PlayState.instance.currentStage.getBoyfriend().getScreenPosition().x;
-		var bfYPos = PlayState.instance.currentStage.getBoyfriend().getScreenPosition().y;
-		bf = new Boyfriend(bfXPos, bfYPos, daBf);
-		add(bf);
+		playBlueBalledSFX();
 
-		camFollow = new FlxObject(bf.getGraphicMidpoint().x, bf.getGraphicMidpoint().y, 1, 1);
-		add(camFollow);
-
-		FlxG.sound.play(Paths.sound('fnf_loss_sfx' + stageSuffix));
-		// Conductor.changeBPM(100);
-
-		switch (PlayState.currentSong.player1)
+		switch (PlayState.instance.currentStageId)
 		{
-			case 'pico':
-				stageSuffix = 'Pico';
+			case 'school' | 'schoolEvil':
+				musicVariant = "-pixel";
+			default:
+				if (PlayState.instance.currentStage.getBoyfriend().characterId == 'pico')
+				{
+					musicVariant = "Pico";
+				}
+				else
+				{
+					musicVariant = "";
+				}
 		}
 
-		// FlxG.camera.followLerp = 1;
-		// FlxG.camera.focusOn(FlxPoint.get(FlxG.width / 2, FlxG.height / 2));
+		// We have to remove boyfriend from the stage. Then we can add him back at the end.
+		boyfriend = PlayState.instance.currentStage.getBoyfriend(true);
+		boyfriend.isDead = true;
+		boyfriend.playAnimation('firstDeath');
+		add(boyfriend);
 
-		// commented out for now
-		FlxG.camera.scroll.set();
+		cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1);
+		add(cameraFollowPoint);
+
+		// FlxG.camera.scroll.set();
 		FlxG.camera.target = null;
-
-		bf.playAnim('firstDeath');
-
-		var randomCensor:Array<Int> = [];
-
-		if (PreferencesMenu.getPref('censor-naughty'))
-			randomCensor = [1, 3, 8, 13, 17, 21];
-
-		randomGameover = FlxG.random.int(1, 25, randomCensor);
+		FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.01);
 	}
 
-	var playingDeathSound:Bool = false;
-
 	override function update(elapsed:Float)
 	{
 		// makes the lerp non-dependant on the framerate
@@ -96,14 +102,14 @@ class GameOverSubstate extends MusicBeatSubstate
 			var touch = FlxG.touches.getFirst();
 			if (touch != null)
 			{
-				if (touch.overlaps(bf))
-					endBullshit();
+				if (touch.overlaps(boyfriend))
+					confirmDeath();
 			}
 		}
 
 		if (controls.ACCEPT)
 		{
-			endBullshit();
+			confirmDeath();
 		}
 
 		if (controls.BACK)
@@ -119,74 +125,129 @@ class GameOverSubstate extends MusicBeatSubstate
 				FlxG.switchState(new FreeplayState());
 		}
 
-		if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.curFrame == 12)
+		// Start panning the camera to BF after 12 frames.
+		// TODO: Should this be de-hardcoded?
+		if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.animation.curAnim.curFrame == 12)
 		{
-			FlxG.camera.follow(camFollow, LOCKON, 0.01);
-		}
-
-		switch (PlayState.storyWeek)
-		{
-			case 7:
-				if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.finished && !playingDeathSound)
-				{
-					playingDeathSound = true;
-
-					bf.startedDeath = true;
-					coolStartDeath(0.2);
-
-					FlxG.sound.play(Paths.sound('jeffGameover/jeffGameover-' + randomGameover), 1, false, null, true, function()
-					{
-						if (!isEnding)
-						{
-							gameOverMusic.fadeIn(4, 0.2, 1);
-						}
-						// FlxG.sound.music.fadeIn(4, 0.2, 1);
-					});
-				}
-			default:
-				if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.finished)
-				{
-					bf.startedDeath = true;
-					coolStartDeath();
-				}
+			cameraFollowPoint.x = boyfriend.getGraphicMidpoint().x;
+			cameraFollowPoint.y = boyfriend.getGraphicMidpoint().y;
 		}
 
 		if (gameOverMusic.playing)
 		{
 			Conductor.songPosition = gameOverMusic.time;
 		}
+		else
+		{
+			switch (PlayState.storyWeek)
+			{
+				case 7:
+					if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished() && !playingJeffQuote)
+					{
+						playingJeffQuote = true;
+						playJeffQuote();
+
+						startDeathMusic(0.2);
+					}
+				default:
+					if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished())
+					{
+						startDeathMusic();
+					}
+			}
+		}
+
+		dispatchEvent(new UpdateScriptEvent(elapsed));
 	}
 
-	private function coolStartDeath(?vol:Float = 1):Void
+	override function dispatchEvent(event:ScriptEvent)
+	{
+		super.dispatchEvent(event);
+
+		ScriptEventDispatcher.callEvent(boyfriend, event);
+	}
+
+	/**
+	 * Starts the death music at the appropriate volume.
+	 * @param startingVolume 
+	 */
+	function startDeathMusic(?startingVolume:Float = 1):Void
 	{
 		if (!isEnding)
 		{
-			gameOverMusic.loadEmbedded(Paths.music('gameOver' + stageSuffix));
-			gameOverMusic.volume = vol;
+			gameOverMusic.loadEmbedded(Paths.music('gameOver' + musicVariant));
+			gameOverMusic.volume = startingVolume;
+			gameOverMusic.play();
+		}
+		else
+		{
+			gameOverMusic.loadEmbedded(Paths.music('gameOverEnd' + musicVariant));
+			gameOverMusic.volume = startingVolume;
 			gameOverMusic.play();
 		}
-		// FlxG.sound.playMusic();
 	}
 
-	var isEnding:Bool = false;
+	/**
+	 * Play the sound effect that occurs when
+	 * boyfriend's testicles get utterly annihilated.
+	 */
+	function playBlueBalledSFX()
+	{
+		FlxG.sound.play(Paths.sound('fnf_loss_sfx' + musicVariant));
+	}
 
-	function endBullshit():Void
+	var playingJeffQuote:Bool = false;
+
+	/**
+	 * Week 7-specific hardcoded behavior, to play a custom death quote.
+	 * TODO: Make this a module somehow.
+	 */
+	function playJeffQuote()
+	{
+		var randomCensor:Array<Int> = [];
+
+		if (PreferencesMenu.getPref('censor-naughty'))
+			randomCensor = [1, 3, 8, 13, 17, 21];
+
+		FlxG.sound.play(Paths.sound('jeffGameover/jeffGameover-' + FlxG.random.int(1, 25, randomCensor)), 1, false, null, true, function()
+		{
+			// Once the quote ends, fade in the game over music.
+			if (!isEnding && gameOverMusic != null)
+			{
+				gameOverMusic.fadeIn(4, 0.2, 1);
+			}
+		});
+	}
+
+	/**
+	 * Do behavior which occurs when you confirm and move to restart the level.
+	 */
+	function confirmDeath():Void
 	{
 		if (!isEnding)
 		{
 			isEnding = true;
-			bf.playAnim('deathConfirm', true);
-			gameOverMusic.stop();
-			// FlxG.sound.music.stop();
-			FlxG.sound.play(Paths.music('gameOverEnd' + stageSuffix));
+			startDeathMusic(); // isEnding changes this function's behavior.
+
+			boyfriend.playAnimation('deathConfirm', true);
+
+			// After the animation finishes...
 			new FlxTimer().start(0.7, function(tmr:FlxTimer)
 			{
+				// ...fade out the graphics. Then after that happens...
 				FlxG.camera.fade(FlxColor.BLACK, 2, false, function()
 				{
+					// ...close the GameOverSubstate.
 					FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true);
 					PlayState.needsReset = true;
+
+					// Readd Boyfriend to the stage.
+					boyfriend.isDead = false;
+					remove(boyfriend);
+					PlayState.instance.currentStage.addCharacter(boyfriend, BF);
+
+					// Close the substate.
 					close();
-					// LoadingState.loadAndSwitchState(new PlayState());
 				});
 			});
 		}
diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx
index 2dce0272b..f27e21b15 100644
--- a/source/funkin/MusicBeatState.hx
+++ b/source/funkin/MusicBeatState.hx
@@ -1,5 +1,9 @@
 package funkin;
 
+import funkin.play.stage.StageData.StageDataParser;
+import funkin.play.character.CharacterData.CharacterDataParser;
+import flixel.FlxState;
+import flixel.FlxSubState;
 import flixel.util.FlxColor;
 import flixel.text.FlxText;
 import funkin.modding.events.ScriptEvent;
@@ -8,6 +12,10 @@ import funkin.modding.events.ScriptEvent.UpdateScriptEvent;
 import funkin.Conductor.BPMChangeEvent;
 import flixel.addons.ui.FlxUIState;
 
+/**
+ * MusicBeatState actually represents the core utility FlxState of the game.
+ * It includes functionality for event handling, as well as maintaining BPM-based update events.
+ */
 class MusicBeatState extends FlxUIState
 {
 	private var curStep:Int = 0;
@@ -21,6 +29,19 @@ class MusicBeatState extends FlxUIState
 	public var leftWatermarkText:FlxText = null;
 	public var rightWatermarkText:FlxText = null;
 
+	public function new()
+	{
+		super();
+
+		initCallbacks();
+	}
+
+	function initCallbacks()
+	{
+		subStateOpened.add(onOpenSubstateComplete);
+		subStateClosed.add(onCloseSubstateComplete);
+	}
+
 	override function create()
 	{
 		super.create();
@@ -35,6 +56,10 @@ class MusicBeatState extends FlxUIState
 	{
 		super.update(elapsed);
 
+		// This can now be used in EVERY STATE YAY!
+		if (FlxG.keys.justPressed.F5)
+			debug_refreshModules();
+
 		// everyStep();
 		var oldStep:Int = curStep;
 
@@ -71,6 +96,23 @@ class MusicBeatState extends FlxUIState
 		ModuleHandler.callEvent(event);
 	}
 
+	function debug_refreshModules()
+	{
+		ModuleHandler.clearModuleCache();
+
+		// Forcibly reload scripts so that scripted stages can be edited.
+		polymod.hscript.PolymodScriptClass.clearScriptClasses();
+		polymod.hscript.PolymodScriptClass.registerAllScriptClasses();
+
+		// Reload the stages in cache. This might cause a lag spike but who cares this is a debug utility.
+		StageDataParser.loadStageCache();
+		CharacterDataParser.loadCharacterCache();
+		ModuleHandler.loadModuleCache();
+
+		// Create a new instance of the current state class.
+		FlxG.resetState();
+	}
+
 	private function updateBeat():Void
 	{
 		curBeat = Math.floor(curStep / 4);
@@ -103,4 +145,56 @@ class MusicBeatState extends FlxUIState
 		lastBeatHitTime = Conductor.songPosition;
 		// do literally nothing dumbass
 	}
+
+	override function switchTo(nextState:FlxState):Bool
+	{
+		var event = new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_BEGIN, nextState, true);
+
+		dispatchEvent(event);
+
+		if (event.eventCanceled)
+		{
+			return false;
+		}
+
+		return super.switchTo(nextState);
+	}
+
+	public override function openSubState(targetSubstate:FlxSubState):Void
+	{
+		var event = new SubStateScriptEvent(ScriptEvent.SUBSTATE_OPEN_BEGIN, targetSubstate, true);
+
+		dispatchEvent(event);
+
+		if (event.eventCanceled)
+		{
+			return;
+		}
+
+		super.openSubState(targetSubstate);
+	}
+
+	function onOpenSubstateComplete(targetState:FlxSubState):Void
+	{
+		dispatchEvent(new SubStateScriptEvent(ScriptEvent.SUBSTATE_OPEN_END, targetState, true));
+	}
+
+	public override function closeSubState():Void
+	{
+		var event = new SubStateScriptEvent(ScriptEvent.SUBSTATE_CLOSE_BEGIN, this.subState, true);
+
+		dispatchEvent(event);
+
+		if (event.eventCanceled)
+		{
+			return;
+		}
+
+		super.closeSubState();
+	}
+
+	function onCloseSubstateComplete(targetState:FlxSubState):Void
+	{
+		dispatchEvent(new SubStateScriptEvent(ScriptEvent.SUBSTATE_CLOSE_END, targetState, true));
+	}
 }
diff --git a/source/funkin/MusicBeatSubstate.hx b/source/funkin/MusicBeatSubstate.hx
index 8374848e4..12cb56cff 100644
--- a/source/funkin/MusicBeatSubstate.hx
+++ b/source/funkin/MusicBeatSubstate.hx
@@ -1,8 +1,13 @@
 package funkin;
 
+import funkin.modding.module.ModuleHandler;
+import funkin.modding.events.ScriptEvent;
 import funkin.Conductor.BPMChangeEvent;
 import flixel.FlxSubState;
 
+/**
+ * MusicBeatSubstate reincorporates the functionality of MusicBeatState into an FlxSubState.
+ */
 class MusicBeatSubstate extends FlxSubState
 {
 	public function new()
@@ -53,6 +58,11 @@ class MusicBeatSubstate extends FlxSubState
 			beatHit();
 	}
 
+	function dispatchEvent(event:ScriptEvent)
+	{
+		ModuleHandler.callEvent(event);
+	}
+
 	public function beatHit():Void
 	{
 		// do literally nothing dumbass
diff --git a/source/funkin/Note.hx b/source/funkin/Note.hx
index d523d59a5..ab56f938e 100644
--- a/source/funkin/Note.hx
+++ b/source/funkin/Note.hx
@@ -227,6 +227,7 @@ class Note extends FlxSprite
 	{
 		super.update(elapsed);
 
+		// mustPress indicates the player is the one pressing the key
 		if (mustPress)
 		{
 			// miss on the NEXT frame so lag doesnt make u miss notes
@@ -244,7 +245,8 @@ class Note extends FlxSprite
 				}
 
 				if (data.strumTime > Conductor.songPosition - HIT_WINDOW)
-				{ // * 0.5 if sustain note, so u have to keep holding it closer to all the way thru!
+				{
+					// * 0.5 if sustain note, so u have to keep holding it closer to all the way thru!
 					if (data.strumTime < Conductor.songPosition + (HIT_WINDOW * (isSustainNote ? 0.5 : 1)))
 						canBeHit = true;
 				}
@@ -455,7 +457,12 @@ enum abstract NoteColor(NoteType) from Int to Int from NoteType
 
 enum abstract NoteKind(String) from String to String
 {
+	/**
+	 * The default note type.
+	 */
 	var NORMAL = "normal";
+
+	// Testing shiz
 	var PYRO_LIGHT = "pyro_light";
 	var PYRO_KICK = "pyro_kick";
 	var PYRO_TOSS = "pyro_toss";
diff --git a/source/funkin/StoryMenuState.hx b/source/funkin/StoryMenuState.hx
index dff3fce14..5cfdd38c1 100644
--- a/source/funkin/StoryMenuState.hx
+++ b/source/funkin/StoryMenuState.hx
@@ -34,6 +34,8 @@ class StoryMenuState extends MusicBeatState
 	];
 	var curDifficulty:Int = 1;
 
+	// TODO: This info is just hardcoded right now.
+	// We should probably make it so that weeks must be completed in order to unlock the next week.
 	public static var weekUnlocked:Array<Bool> = [true, true, true, true, true, true, true, true];
 
 	var weekCharacters:Array<Dynamic> = [
diff --git a/source/funkin/i18n/FireTongueHandler.hx b/source/funkin/i18n/FireTongueHandler.hx
deleted file mode 100644
index fdb13e41c..000000000
--- a/source/funkin/i18n/FireTongueHandler.hx
+++ /dev/null
@@ -1,114 +0,0 @@
-package funkin.i18n;
-
-import firetongue.FireTongue;
-
-class FireTongueHandler
-{
-	static final DEFAULT_LOCALE = 'en-US';
-	// static final DEFAULT_LOCALE = 'pt-BR';
-	static final LOCALE_DIR = 'assets/locales/';
-
-	static var tongue:FireTongue;
-
-	/**
-	 * Initialize the FireTongue instance.
-	 * This will automatically start with the default locale for you.
-	 */
-	public static function init():Void
-	{
-		tongue = new FireTongue(OPENFL, // Haxe framework being used.
-			// This should really have been a parameterized object...
-			null, // Function to check if a file exists. Specify null to use the one from the framework.
-			null, // Function to retrieve the text of a file.	Specify null to use the one from the framework.
-			null, // Function to get a list of files in a directory. Specify null to use the one from the framework.
-			firetongue.FireTongue.Case.Upper);
-
-		// TODO: Make this use the language from the user's preferences.
-		setLanguage(DEFAULT_LOCALE);
-
-		trace('[FIRETONGUE] Initialized. Available locales: ${tongue.locales.join(', ')}');
-	}
-
-	/**
-	 * Switch the language used by FireTongue.
-	 * @param locale The name of the locale to use, such as `en-US`.
-	 */
-	public static function setLanguage(locale:String):Void
-	{
-		tongue.initialize({
-			locale: locale, // The locale to load.
-
-			finishedCallback: onFinishLoad, // Function run when the locale is loaded.
-			directory: LOCALE_DIR, // Folder (relative to assets/) to load data from.
-			replaceMissing: false, // If true, missing flags fallback to the default locale.
-			checkMissing: true, // If true, check for and store the list of missing flags for this locale.
-		});
-	}
-
-	/**
-	 * Function called when FireTongue finishes loading a language.
-	 */
-	static function onFinishLoad()
-	{
-		if (tongue == null)
-			return;
-
-		trace('[FIRETONGUE] Finished loading locale: ${tongue.locale}');
-		if (tongue.missingFlags != null)
-		{
-			if (tongue.missingFlags.get(tongue.locale) != null && tongue.missingFlags.get(tongue.locale).length != 0)
-			{
-				trace('[FIRETONGUE] Missing flags: ${tongue.missingFlags.get(tongue.locale).join(', ')}');
-			}
-			else
-			{
-				trace('[FIRETONGUE] No missing flags for this locale. (Note: Another locale has missing flags.)');
-			}
-		}
-		else
-		{
-			trace('[FIRETONGUE] No missing flags.');
-		}
-
-		trace('[FIRETONGUE] HELLO_WORLD = ${t("HELLO_WORLD")}');
-	}
-
-	/**
-	 * Retrieve a localized string based on the given key.
-	 * 
-	 * Example:
-	 * import i18n.FiretongueHandler.t;
-	 * trace(t('HELLO')); // Prints "Hello!"
-	 * 
-	 * @param key The key to use to retrieve the localized string.
-	 * @param context The data file to load the key from.
-	 * @return The localized string.
-	 */
-	public static function t(key:String, context:String = 'data'):String
-	{
-		// The localization strings can be stored all in one file,
-		// or split into several contexts.
-		return tongue.get(key, context);
-	}
-
-	/**
-	 * Retrieve a localized string while replacing specific values.
-	 * In this way, you can use the same invocation call to properly localize
-	 * a variety of different languages with distinct grammar.
-	 * 
-	 * Example:
-	 * import i18n.FiretongueHandler.f;
-	 * trace(f('COLLECT_X_APPLES', 'data', ['<X>'], ['10']); // Prints "Collect 10 apples!"
-	 * 
-	 * @param key The key to use to retrieve the localized string.
-	 * @param context The data file to load the key from.
-	 * @param flags The flags to replace in the string.
-	 * @param values The values to replace those flags with.
-	 * @return The localized string.
-	 */
-	public static function f(key:String, context:String = 'data', flags:Array<String> = null, values:Array<String> = null):String
-	{
-		var str = t(key, context);
-		return firetongue.Replace.flags(str, flags, values);
-	}
-}
diff --git a/source/funkin/i18n/README.md b/source/funkin/i18n/README.md
deleted file mode 100644
index d55a8f41c..000000000
--- a/source/funkin/i18n/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# i18n
-
-This package contains functions used for internationalization (i18n).
diff --git a/source/funkin/modding/IScriptedClass.hx b/source/funkin/modding/IScriptedClass.hx
index 9691c1417..70ab28983 100644
--- a/source/funkin/modding/IScriptedClass.hx
+++ b/source/funkin/modding/IScriptedClass.hx
@@ -23,6 +23,20 @@ interface IStateChangingScriptedClass extends IScriptedClass
 {
 	public function onStateChangeBegin(event:StateChangeScriptEvent):Void;
 	public function onStateChangeEnd(event:StateChangeScriptEvent):Void;
+
+	public function onSubstateOpenBegin(event:SubStateScriptEvent):Void;
+	public function onSubstateOpenEnd(event:SubStateScriptEvent):Void;
+	public function onSubstateCloseBegin(event:SubStateScriptEvent):Void;
+	public function onSubstateCloseEnd(event:SubStateScriptEvent):Void;
+}
+
+/**
+ * Defines a set of callbacks available to scripted classes which represent notes.
+ */
+interface INoteScriptedClass extends IScriptedClass
+{
+	public function onNoteHit(event:NoteScriptEvent):Void;
+	public function onNoteMiss(event:NoteScriptEvent):Void;
 }
 
 /**
@@ -46,12 +60,12 @@ interface IPlayStateScriptedClass extends IScriptedClass
 	public function onSongLoaded(eent:SongLoadScriptEvent):Void;
 	public function onSongStart(event:ScriptEvent):Void;
 	public function onSongEnd(event:ScriptEvent):Void;
-	public function onSongReset(event:ScriptEvent):Void;
 	public function onGameOver(event:ScriptEvent):Void;
-	public function onGameRetry(event:ScriptEvent):Void;
+	public function onSongRetry(event:ScriptEvent):Void;
 
 	public function onNoteHit(event:NoteScriptEvent):Void;
 	public function onNoteMiss(event:NoteScriptEvent):Void;
+	public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void;
 
 	public function onStepHit(event:SongTimeScriptEvent):Void;
 	public function onBeatHit(event:SongTimeScriptEvent):Void;
diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index ce4c08743..1c55616fc 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -1,5 +1,8 @@
 package funkin.modding.events;
 
+import flixel.FlxState;
+import flixel.FlxSubState;
+import funkin.Note.NoteDir;
 import funkin.play.Countdown.CountdownStep;
 import openfl.events.EventType;
 import openfl.events.KeyboardEvent;
@@ -83,6 +86,15 @@ class ScriptEvent
 	 */
 	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 the song starts. This occurs as the countdown ends and the instrumental and vocals begin.
 	 * 
@@ -97,13 +109,6 @@ class ScriptEvent
 	 */
 	public static inline final SONG_END:ScriptEventType = "SONG_END";
 
-	/**
-	 * Called when the song is reset. This can happen from the pause menu or the game over screen.
-	 * 
-	 * This event is not cancelable.
-	 */
-	public static inline final SONG_RESET:ScriptEventType = "SONG_RESET";
-
 	/**
 	 * Called when the countdown begins. This occurs before the song starts.
 	 * 
@@ -130,18 +135,19 @@ class ScriptEvent
 	public static inline final COUNTDOWN_END:ScriptEventType = "COUNTDOWN_END";
 
 	/**
-	 * Called when the game over screen triggers and the death animation plays.
+	 * 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 presses a key to restart the game after the death animation.
+	 * Called when 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 GAME_RETRY:ScriptEventType = "GAME_RETRY";
+	public static inline final SONG_RETRY:ScriptEventType = "SONG_RETRY";
 
 	/**
 	 * Called when the player pushes down any key on the keyboard.
@@ -166,11 +172,46 @@ class ScriptEvent
 	public static inline final SONG_LOADED:ScriptEventType = "SONG_LOADED";
 
 	/**
-	 * Called when the game is entering the current FlxState.
+	 * Called when the game is about to switch the current FlxState.
 	 * 
 	 * This event is not cancelable.
 	 */
-	public static inline final STATE_ENTER:ScriptEventType = "STATE_ENTER";
+	public static inline final STATE_CHANGE_BEGIN:ScriptEventType = "STATE_CHANGE_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 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 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 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 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 exiting the current FlxState.
@@ -265,6 +306,59 @@ class NoteScriptEvent extends ScriptEvent
 	}
 }
 
+/**
+ * An event that is fired when you press a key with no note present.
+ */
+class GhostMissNoteScriptEvent extends ScriptEvent
+{
+	/**
+	 * 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;
+
+	/**
+	 * 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;
+
+	/**
+	 * 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;
+
+	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 + ')';
+	}
+}
+
 /**
  * An event that is fired during the update loop.
  */
@@ -403,13 +497,41 @@ class SongLoadScriptEvent extends ScriptEvent
  */
 class StateChangeScriptEvent extends ScriptEvent
 {
-	public function new(type:ScriptEventType):Void
+	/**
+	 * 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, false);
+		super(type, cancelable);
+		this.targetState = targetState;
 	}
 
 	public override function toString():String
 	{
-		return 'StateChangeScriptEvent(type=' + type + ')';
+		return 'StateChangeScriptEvent(type=' + type + ', targetState=' + targetState + ')';
+	}
+}
+
+/**
+ * An event that is fired when moving out of or into an FlxSubState.
+ */
+class SubStateScriptEvent extends ScriptEvent
+{
+	/**
+	 * 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 override function toString():String
+	{
+		return 'SubStateScriptEvent(type=' + type + ', targetState=' + targetState + ')';
 	}
 }
diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx
index ecb97a846..45edf5214 100644
--- a/source/funkin/modding/events/ScriptEventDispatcher.hx
+++ b/source/funkin/modding/events/ScriptEventDispatcher.hx
@@ -35,18 +35,6 @@ class ScriptEventDispatcher
 				return;
 		}
 
-		if (Std.isOfType(target, IStateChangingScriptedClass))
-		{
-			var t = cast(target, IStateChangingScriptedClass);
-			var t = cast(target, IPlayStateScriptedClass);
-			switch (event.type)
-			{
-				case ScriptEvent.NOTE_HIT:
-					t.onNoteHit(cast event);
-					return;
-			}
-		}
-
 		if (Std.isOfType(target, IPlayStateScriptedClass))
 		{
 			var t = cast(target, IPlayStateScriptedClass);
@@ -58,6 +46,9 @@ class ScriptEventDispatcher
 				case ScriptEvent.NOTE_MISS:
 					t.onNoteMiss(cast event);
 					return;
+				case ScriptEvent.NOTE_GHOST_MISS:
+					t.onNoteGhostMiss(cast event);
+					return;
 				case ScriptEvent.SONG_BEAT_HIT:
 					t.onBeatHit(cast event);
 					return;
@@ -70,8 +61,11 @@ class ScriptEventDispatcher
 				case ScriptEvent.SONG_END:
 					t.onSongEnd(event);
 					return;
-				case ScriptEvent.SONG_RESET:
-					t.onSongReset(event);
+				case ScriptEvent.SONG_RETRY:
+					t.onSongRetry(event);
+					return;
+				case ScriptEvent.GAME_OVER:
+					t.onGameOver(event);
 					return;
 				case ScriptEvent.PAUSE:
 					t.onPause(event);
@@ -94,7 +88,38 @@ class ScriptEventDispatcher
 			}
 		}
 
-		throw "No helper for event type: " + event.type;
+		if (Std.isOfType(target, IStateChangingScriptedClass))
+		{
+			var t = cast(target, IStateChangingScriptedClass);
+			switch (event.type)
+			{
+				case ScriptEvent.STATE_CHANGE_BEGIN:
+					t.onStateChangeBegin(cast event);
+					return;
+				case ScriptEvent.STATE_CHANGE_END:
+					t.onStateChangeEnd(cast event);
+					return;
+				case ScriptEvent.SUBSTATE_OPEN_BEGIN:
+					t.onSubstateOpenBegin(cast event);
+					return;
+				case ScriptEvent.SUBSTATE_OPEN_END:
+					t.onSubstateOpenEnd(cast event);
+					return;
+				case ScriptEvent.SUBSTATE_CLOSE_BEGIN:
+					t.onSubstateCloseBegin(cast event);
+					return;
+				case ScriptEvent.SUBSTATE_CLOSE_END:
+					t.onSubstateCloseEnd(cast event);
+					return;
+			}
+		}
+		else
+		{
+			// Prevent "NO HELPER error."
+			return;
+		}
+
+		throw "No function called for event type: " + event.type;
 	}
 
 	public static function callEventOnAllTargets(targets:Iterator<IScriptedClass>, event:ScriptEvent):Void
diff --git a/source/funkin/modding/module/Module.hx b/source/funkin/modding/module/Module.hx
index 3f499c5e1..207fc2a4a 100644
--- a/source/funkin/modding/module/Module.hx
+++ b/source/funkin/modding/module/Module.hx
@@ -18,7 +18,7 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte
 	/**
 	 * Whether the module is currently active.
 	 */
-	public var active(default, set):Bool = false;
+	public var active(default, set):Bool = true;
 
 	function set_active(value:Bool):Bool
 	{
@@ -48,14 +48,11 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte
 	 * Called when the module is initialized.
 	 * It may not be safe to reference other modules here since they may not be loaded yet.
 	 * 
-	 * @param startActive Whether to start with the module active.
-	 *   If false, the module will be inactive and must be enabled by another script,
-	 *   such as a stage or another module.
+	 * NOTE: To make the module start inactive, call `this.active = false` in the constructor.
 	 */
-	public function new(moduleId:String, active:Bool = true, priority:Int = 1000):Void
+	public function new(moduleId:String, priority:Int = 1000):Void
 	{
 		this.moduleId = moduleId;
-		this.active = active;
 		this.priority = priority;
 	}
 
@@ -90,16 +87,14 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte
 
 	public function onSongEnd(event:ScriptEvent) {}
 
-	public function onSongReset(event:ScriptEvent) {}
-
 	public function onGameOver(event:ScriptEvent) {}
 
-	public function onGameRetry(event:ScriptEvent) {}
-
 	public function onNoteHit(event:NoteScriptEvent) {}
 
 	public function onNoteMiss(event:NoteScriptEvent) {}
 
+	public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {}
+
 	public function onStepHit(event:SongTimeScriptEvent) {}
 
 	public function onBeatHit(event:SongTimeScriptEvent) {}
@@ -110,9 +105,19 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte
 
 	public function onCountdownEnd(event:CountdownScriptEvent) {}
 
-	public function onSongLoaded(eent:SongLoadScriptEvent) {}
+	public function onSongLoaded(event:SongLoadScriptEvent) {}
 
 	public function onStateChangeBegin(event:StateChangeScriptEvent) {}
 
 	public function onStateChangeEnd(event:StateChangeScriptEvent) {}
+
+	public function onSubstateOpenBegin(event:SubStateScriptEvent) {}
+
+	public function onSubstateOpenEnd(event:SubStateScriptEvent) {}
+
+	public function onSubstateCloseBegin(event:SubStateScriptEvent) {}
+
+	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 908b5c428..e533a39fd 100644
--- a/source/funkin/modding/module/ModuleHandler.hx
+++ b/source/funkin/modding/module/ModuleHandler.hx
@@ -47,6 +47,16 @@ class ModuleHandler
 		trace("[MODULEHANDLER] Module cache loaded.");
 	}
 
+	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 addToModuleCache(module:Module):Void
 	{
 		moduleCache.set(module.moduleId, module);
diff --git a/source/funkin/play/AnimationData.hx b/source/funkin/play/AnimationData.hx
index 5a9586b9f..1d75e82a5 100644
--- a/source/funkin/play/AnimationData.hx
+++ b/source/funkin/play/AnimationData.hx
@@ -16,11 +16,18 @@ typedef AnimationData =
 	 */
 	var prefix:String;
 
+	/**
+	 * Optionally specify an asset path to use for this specific animation.
+	 * ONLY for use by MultiSparrow characters.
+	 * @default The assetPath of the parent sprite
+	 */
+	var assetPath:Null<String>;
+
 	/**
 	 * Offset the character's position by this amount when playing this animation.
 	 * @default [0, 0]
 	 */
-	var offsets:Null<Array<Float>>;
+	var offsets:Null<Array<Int>>;
 
 	/**
 	 * Whether the animation should loop when it finishes.
diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index 8413110ca..ec6fd9dce 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -37,6 +37,9 @@ class Countdown
 		PlayState.isInCountdown = true;
 		Conductor.songPosition = Conductor.crochet * -5;
 		countdownStep = BEFORE;
+		// Handle onBeatHit events manually
+		@:privateAccess
+		PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0));
 
 		var cancelled:Bool = propagateCountdownEvent(countdownStep);
 		if (cancelled)
@@ -49,9 +52,9 @@ class Countdown
 		{
 			countdownStep = decrement(countdownStep);
 
-			// Play the dance animations manually.
+			// Handle onBeatHit events manually
 			@:privateAccess
-			PlayState.instance.danceOnBeat();
+			PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0));
 
 			// Countdown graphic.
 			showCountdownGraphic(countdownStep, isPixelStyle);
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 05353d155..9471737f5 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1,6 +1,6 @@
 package funkin.play;
 
-import funkin.play.character.CharacterBase;
+import funkin.play.character.BaseCharacter;
 import flixel.addons.effects.FlxTrail;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxCamera;
@@ -86,6 +86,12 @@ class PlayState extends MusicBeatState implements IHook
 	 */
 	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.
@@ -125,6 +131,11 @@ class PlayState extends MusicBeatState implements IHook
 	 */
 	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.
@@ -225,7 +236,6 @@ class PlayState extends MusicBeatState implements IHook
 	public static var storyWeek:Int = 0;
 	public static var storyPlaylist:Array<String> = [];
 	public static var storyDifficulty:Int = 1;
-	public static var needsReset:Bool = false;
 	public static var seenCutscene:Bool = false;
 	public static var campaignScore:Int = 0;
 
@@ -242,7 +252,6 @@ class PlayState extends MusicBeatState implements IHook
 
 	var dialogue:Array<String>;
 	var talking:Bool = true;
-	var songScore:Int = 0;
 	var doof:DialogueBox;
 	var grpNoteSplashes:FlxTypedGroup<NoteSplash>;
 	var comboPopUps:PopUpStuff;
@@ -377,6 +386,8 @@ class PlayState extends MusicBeatState implements IHook
 		iconP1.cameras = [camHUD];
 		iconP2.cameras = [camHUD];
 		scoreText.cameras = [camHUD];
+		leftWatermarkText.cameras = [camHUD];
+		rightWatermarkText.cameras = [camHUD];
 
 		// if (SONG.song == 'South')
 		// FlxG.camera.alpha = 0.7;
@@ -494,26 +505,48 @@ class PlayState extends MusicBeatState implements IHook
 		if (currentSong.song.toLowerCase() == 'stress')
 			gfVersion = 'pico-speaker';
 
-		var girlfriend:Character = new Character(350, -70, gfVersion);
-		girlfriend.scrollFactor.set(0.95, 0.95);
-		if (gfVersion == 'pico-speaker')
+		if (currentSong.song.toLowerCase() == 'tutorial')
+			gfVersion = '';
+
+		//
+		// GIRLFRIEND
+		//
+		var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(gfVersion);
+
+		if (girlfriend != null)
 		{
-			girlfriend.x -= 50;
-			girlfriend.y -= 200;
+			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 = new Character(100, 100, currentSong.player2);
+		var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player2);
 
-		cameraFollowPoint.setPosition(dad.getGraphicMidpoint().x, dad.getGraphicMidpoint().y);
+		if (dad != null)
+		{
+			dad.characterType = CharacterType.DAD;
+			cameraFollowPoint.setPosition(dad.getGraphicMidpoint().x, dad.getGraphicMidpoint().y);
+		}
 
 		switch (currentSong.player2)
 		{
 			case 'gf':
-				dad.setPosition(girlfriend.x, girlfriend.y);
-				girlfriend.visible = false;
+				var gfPoint:FlxPoint = currentStage.getGirlfriendPosition();
+				dad.setPosition(gfPoint.x, gfPoint.y);
+
+				// girlfriend.visible = false;
+
 				if (isStoryMode)
 				{
 					cameraFollowPoint.x += 600;
@@ -553,12 +586,11 @@ class PlayState extends MusicBeatState implements IHook
 		//
 		// BOYFRIEND
 		//
-		var boyfriend:CharacterBase;
-		switch (currentSong.player1)
+		var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player1);
+
+		if (boyfriend != null)
 		{
-			default:
-				boyfriend = CharacterDataParser.fetchCharacter(currentSong.player1);
-				boyfriend.characterType = CharacterType.BF;
+			boyfriend.characterType = CharacterType.BF;
 		}
 
 		// REPOSITIONING PER STAGE
@@ -589,8 +621,8 @@ class PlayState extends MusicBeatState implements IHook
 			// We're using Eric's stage handler.
 			// Characters get added to the stage, not the main scene.
 			currentStage.addCharacter(boyfriend, BF);
-			currentStage.addCharacterOld(girlfriend, GF);
-			currentStage.addCharacterOld(dad, DAD);
+			currentStage.addCharacter(girlfriend, GF);
+			currentStage.addCharacter(dad, DAD);
 
 			// Redo z-indexes.
 			currentStage.refresh();
@@ -612,7 +644,7 @@ class PlayState extends MusicBeatState implements IHook
 	 * 
 	 * Call this by pressing F5 on a debug build.
 	 */
-	function debug_refreshStages()
+	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.
@@ -624,19 +656,7 @@ class PlayState extends MusicBeatState implements IHook
 			currentStage = null;
 		}
 
-		ModuleHandler.clearModuleCache();
-
-		// Forcibly reload scripts so that scripted stages can be edited.
-		polymod.hscript.PolymodScriptClass.clearScriptClasses();
-		polymod.hscript.PolymodScriptClass.registerAllScriptClasses();
-
-		// Reload the stages in cache. This might cause a lag spike but who cares this is a debug utility.
-		StageDataParser.loadStageCache();
-		CharacterDataParser.loadCharacterCache();
-		ModuleHandler.loadModuleCache();
-
-		// Reload the level. This should use new data from the assets folder.
-		LoadingState.loadAndSwitchState(new PlayState());
+		super.debug_refreshModules();
 	}
 
 	/**
@@ -957,6 +977,8 @@ class PlayState extends MusicBeatState implements IHook
 
 		if (needsReset)
 		{
+			dispatchEvent(new ScriptEvent(ScriptEvent.SONG_RETRY));
+
 			resetCamera();
 
 			persistentUpdate = true;
@@ -967,11 +989,10 @@ class PlayState extends MusicBeatState implements IHook
 			FlxG.sound.music.pause();
 			vocals.pause();
 
-			var event:ScriptEvent = new ScriptEvent(ScriptEvent.SONG_RESET, false);
-
 			FlxG.sound.music.time = 0;
 			regenNoteData(); // loads the note data from start
 			health = 1;
+			songScore = 0;
 			Countdown.performCountdown(currentStageId.startsWith('school'));
 
 			needsReset = false;
@@ -1059,9 +1080,6 @@ class PlayState extends MusicBeatState implements IHook
 		if (FlxG.keys.justPressed.EIGHT)
 			FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
 
-		if (FlxG.keys.justPressed.F5)
-			debug_refreshStages();
-
 		if (FlxG.keys.justPressed.NINE)
 			iconP1.swapOldIcon();
 
@@ -1158,6 +1176,8 @@ class PlayState extends MusicBeatState implements IHook
 
 				deathCounter += 1;
 
+				dispatchEvent(new ScriptEvent(ScriptEvent.GAME_OVER));
+
 				openSubState(new GameOverSubstate());
 
 				#if discord_rpc
@@ -1226,35 +1246,28 @@ class PlayState extends MusicBeatState implements IHook
 					}
 				}
 
-				if (!daNote.mustPress && daNote.wasGoodHit)
+				if (!daNote.mustPress && daNote.wasGoodHit && !daNote.tooLate)
 				{
 					if (currentSong.song != 'Tutorial')
 						camZooming = true;
 
-					var altAnim:String = "";
+					var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, true);
+					dispatchEvent(event);
 
-					if (SongLoad.getSong()[Math.floor(curStep / 16)] != null)
+					// 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)
 					{
-						if (SongLoad.getSong()[Math.floor(curStep / 16)].altAnim)
-							altAnim = '-alt';
+						daNote.tooLate = true;
 					}
-
-					if (daNote.data.altNote)
-						altAnim = '-alt';
-
-					if (!daNote.isSustainNote)
+					else
 					{
-						currentStage.getDad().playAnim('sing' + daNote.dirNameUpper + altAnim, true);
+						// Volume of DAD.
+						if (currentSong.needsVoices)
+							vocals.volume = 1;
 					}
-
-					currentStage.getDad().holdTimer = 0;
-
-					if (currentSong.needsVoices)
-						vocals.volume = 1;
-
-					daNote.kill();
-					activeNotes.remove(daNote, true);
-					daNote.destroy();
 				}
 
 				// WIP interpolation shit? Need to fix the pause issue
@@ -1279,18 +1292,8 @@ class PlayState extends MusicBeatState implements IHook
 						daNote.destroy();
 					}
 				}
-				else if (daNote.tooLate || daNote.wasGoodHit)
+				if (daNote.wasGoodHit)
 				{
-					// TODO: Why the hell is the noteMiss logic in two different places?
-					if (daNote.tooLate)
-					{
-						var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, daNote, true);
-						dispatchEvent(event);
-						health -= 0.0775;
-						vocals.volume = 0;
-						killCombo();
-					}
-
 					daNote.active = false;
 					daNote.visible = false;
 
@@ -1298,6 +1301,11 @@ class PlayState extends MusicBeatState implements IHook
 					activeNotes.remove(daNote, true);
 					daNote.destroy();
 				}
+
+				if (daNote.tooLate)
+				{
+					noteMiss(daNote);
+				}
 			});
 		}
 
@@ -1329,8 +1337,10 @@ class PlayState extends MusicBeatState implements IHook
 
 	function killCombo():Void
 	{
-		if (combo > 5 && currentStage.getGirlfriend().animOffsets.exists('sad'))
-			currentStage.getGirlfriend().playAnim('sad');
+		// Girlfriend gets sad if you combo break after hitting 5 notes.
+		if (currentStage.getGirlfriend() != null)
+			if (combo > 5 && currentStage.getGirlfriend().hasAnimation('sad'))
+				currentStage.getGirlfriend().playAnimation('sad');
 
 		if (combo != 0)
 		{
@@ -1494,15 +1504,6 @@ class PlayState extends MusicBeatState implements IHook
 
 		health += healthMulti;
 
-		// TODO: Redo note hit logic to make sure this always gets called
-		var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, true);
-		dispatchEvent(event);
-
-		if (event.eventCanceled)
-		{
-			// TODO: Do a thing!
-		}
-
 		if (isSick)
 		{
 			var noteSplash:NoteSplash = grpNoteSplashes.recycle(NoteSplash);
@@ -1531,7 +1532,7 @@ class PlayState extends MusicBeatState implements IHook
 			cameraFollowPoint.setPosition(currentStage.getDad().getMidpoint().x + 150, currentStage.getDad().getMidpoint().y - 100);
 			// camFollow.setPosition(lucky.getMidpoint().x - 120, lucky.getMidpoint().y + 210);
 
-			switch (currentStage.getDad().curCharacter)
+			switch (currentStage.getDad().characterId)
 			{
 				case 'mom':
 					cameraFollowPoint.y = currentStage.getDad().getMidpoint().y;
@@ -1540,7 +1541,7 @@ class PlayState extends MusicBeatState implements IHook
 					cameraFollowPoint.x = currentStage.getDad().getMidpoint().x - 100;
 			}
 
-			if (currentStage.getDad().curCharacter == 'mom')
+			if (currentStage.getDad().characterId == 'mom')
 				vocals.volume = 1;
 
 			if (currentSong.song.toLowerCase() == 'tutorial')
@@ -1573,9 +1574,11 @@ class PlayState extends MusicBeatState implements IHook
 		trace(instance.currentStageId);
 	};
 
-	@:hookable
 	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> = [
@@ -1659,7 +1662,7 @@ class PlayState extends MusicBeatState implements IHook
 				for (shit in 0...pressArray.length)
 				{ // if a direction is hit that shouldn't be
 					if (pressArray[shit] && !directionList.contains(shit))
-						PlayState.instance.noteMiss(shit);
+						PlayState.instance.ghostNoteMiss(shit);
 				}
 				for (coolNote in possibleNotes)
 				{
@@ -1669,23 +1672,15 @@ class PlayState extends MusicBeatState implements IHook
 			}
 			else
 			{
+				// HNGGG I really want to add an option for ghost tapping
 				for (shit in 0...pressArray.length)
 					if (pressArray[shit])
-						PlayState.instance.noteMiss(shit);
+						PlayState.instance.ghostNoteMiss(shit, false);
 			}
 		}
 
 		if (PlayState.instance.currentStage == null)
 			return;
-		if (PlayState.instance.currentStage.getBoyfriend().holdTimer > Conductor.stepCrochet * 4 * 0.001 && !holdArray.contains(true))
-		{
-			if (PlayState.instance.currentStage.getBoyfriend().animation != null
-				&& PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.startsWith('sing')
-				&& !PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.endsWith('miss'))
-			{
-				PlayState.instance.currentStage.getBoyfriend().playAnimation('idle');
-			}
-		}
 
 		for (keyId => isPressed in pressArray)
 		{
@@ -1702,33 +1697,78 @@ class PlayState extends MusicBeatState implements IHook
 		}
 	}
 
-	function noteMiss(direction:NoteDir = 1):Void
+	/**
+	 * 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:NoteType = 1, hasPossibleNotes:Bool = true):Void
 	{
-		// whole function used to be encased in if (!boyfriend.stunned)
-		health -= 0.07;
-		killCombo();
+		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
+	{
+		var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, 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;
-		FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
+		killCombo();
 
-		currentStage.getBoyfriend().playAnimation('sing' + direction.nameUpper + 'miss', true);
+		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, true);
+			dispatchEvent(event);
+
+			// Calling event.cancelEvent() skips all the other logic! Neat!
+			if (event.eventCanceled)
+				return;
+
 			if (!note.isSustainNote)
 			{
 				combo += 1;
 				popUpScore(note.data.strumTime, note);
 			}
 
-			currentStage.getBoyfriend().playAnimation('sing' + note.dirNameUpper, true);
-
 			playerStrumline.getArrow(note.data.noteData).playAnimation('confirm', true);
 
 			note.wasGoodHit = true;
@@ -1813,22 +1853,6 @@ class PlayState extends MusicBeatState implements IHook
 		if (currentStage == null)
 			return;
 
-		if (curBeat % gfSpeed == 0)
-			currentStage.getGirlfriend().dance();
-
-		if (curBeat % 2 == 0)
-		{
-			if (currentStage.getBoyfriend().animation != null && !currentStage.getBoyfriend().animation.curAnim.name.startsWith("sing"))
-				currentStage.getBoyfriend().playAnimation('idle');
-			if (currentStage.getDad().animation != null && !currentStage.getDad().animation.curAnim.name.startsWith("sing"))
-				currentStage.getDad().dance();
-		}
-		else if (currentStage.getDad().curCharacter == 'spooky')
-		{
-			if (!currentStage.getDad().animation.curAnim.name.startsWith("sing"))
-				currentStage.getDad().dance();
-		}
-
 		if (curBeat % 8 == 7 && currentSong.song == 'Bopeebo')
 		{
 			currentStage.getBoyfriend().playAnimation('hey', true);
@@ -1836,12 +1860,12 @@ class PlayState extends MusicBeatState implements IHook
 
 		if (curBeat % 16 == 15
 			&& currentSong.song == 'Tutorial'
-			&& currentStage.getDad().curCharacter == 'gf'
+			&& currentStage.getDad().characterId == 'gf'
 			&& curBeat > 16
 			&& curBeat < 48)
 		{
 			currentStage.getBoyfriend().playAnimation('hey', true);
-			currentStage.getDad().playAnim('cheer', true);
+			currentStage.getDad().playAnimation('cheer', true);
 		}
 	}
 
@@ -1976,12 +2000,20 @@ class PlayState extends MusicBeatState implements IHook
 
 	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);
 
-		// TODO: Dispatch event to song script
-		// TODO: Dispatch events to character scripts
+		// Dispatch event to character script(s).
+		if (currentStage != null)
+			currentStage.dispatchToCharacters(event);
 
-		super.dispatchEvent(event);
+		// TODO: Dispatch event to song script
 	}
 
 	/**
@@ -2045,11 +2077,17 @@ class PlayState extends MusicBeatState implements IHook
 
 	/**
 	 * 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
 	{
-		performCleanup();
+		var result = super.switchTo(nextState);
 
-		return super.switchTo(nextState);
+		if (result)
+		{
+			performCleanup();
+		}
+
+		return result;
 	}
 }
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
new file mode 100644
index 000000000..c833c9942
--- /dev/null
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -0,0 +1,281 @@
+package funkin.play.character;
+
+import funkin.modding.events.ScriptEvent;
+import funkin.modding.events.ScriptEvent.UpdateScriptEvent;
+import funkin.play.character.CharacterData.CharacterDataParser;
+import funkin.Note.NoteDir;
+import funkin.modding.events.ScriptEvent.NoteScriptEvent;
+import funkin.play.stage.Bopper;
+
+using StringTools;
+
+/**
+ * A Character is a stage prop which bops to the music as well as controlled by the strumlines.
+ * 
+ * Remember: The character's origin is at its FEET. (horizontal center, vertical bottom)
+ */
+class BaseCharacter extends Bopper
+{
+	// Metadata about a character.
+	public var characterId(default, null):String;
+	public var characterName(default, null):String;
+
+	/**
+	 * Whether the player is an active character (Boyfriend) or not.
+	 */
+	public var characterType:CharacterType = OTHER;
+
+	final _data:CharacterData;
+
+	/**
+	 * Tracks how long, in seconds, the character has been playing the current `sing` animation.
+	 * This is used to ensure that characters play the `sing` animations for at least one beat,
+	 *   preventing them from reverting to the `idle` animation between notes.
+	 */
+	public var holdTimer:Float = 0;
+
+	final singTimeCrochet:Float;
+
+	public var isDead:Bool = false;
+
+	public function new(id:String)
+	{
+		super();
+		this.characterId = id;
+
+		_data = CharacterDataParser.fetchCharacterData(this.characterId);
+		if (_data == null)
+		{
+			throw 'Could not find character data for characterId: $characterId';
+		}
+		else
+		{
+			this.characterName = _data.name;
+			this.singTimeCrochet = _data.singTime;
+		}
+	}
+
+	public override function onUpdate(event:UpdateScriptEvent):Void
+	{
+		super.onUpdate(event);
+
+		// Reset hold timer for each note pressed.
+		if (justPressedNote())
+		{
+			holdTimer = 0;
+		}
+
+		if (isDead)
+		{
+			playDeathAnimation();
+		}
+
+		// Handle character note hold time.
+		if (getCurrentAnimation().startsWith("sing"))
+		{
+			holdTimer += event.elapsed;
+			var singTimeMs:Float = singTimeCrochet * (Conductor.crochet * 0.001); // x beats, to ms.
+			// Without this check here, the player character would only play the `sing` animation
+			// for one beat, as opposed to holding it as long as the player is holding the button.
+			var shouldStopSinging:Bool = (this.characterType == BF) ? !isHoldingNote() : true;
+
+			FlxG.watch.addQuick('singTimeMs-${characterId}', singTimeMs);
+			if (holdTimer > singTimeMs && shouldStopSinging)
+			{
+				trace('holdTimer reached ${holdTimer}sec (> ${singTimeMs}), stopping sing animation');
+				holdTimer = 0;
+				dance(true);
+			}
+		}
+		else
+		{
+			holdTimer = 0;
+			// super.onBeatHit handles the regular `dance()` calls.
+		}
+		FlxG.watch.addQuick('holdTimer-${characterId}', holdTimer);
+	}
+
+	/**
+	 * Since no `onBeatHit` or `dance` calls happen in GameOverSubstate,
+	 * this regularly gets called instead.
+	 */
+	public function playDeathAnimation(force:Bool = false):Void
+	{
+		if (force || (getCurrentAnimation().startsWith("firstDeath") && isAnimationFinished()))
+		{
+			playAnimation("deathLoop");
+		}
+	}
+
+	override function dance(force:Bool = false)
+	{
+		if (!force)
+		{
+			if (getCurrentAnimation().startsWith("sing"))
+			{
+				return;
+			}
+			if (["hey", "cheer"].contains(getCurrentAnimation()) && !isAnimationFinished())
+			{
+				return;
+			}
+		}
+
+		// Prevent dancing while another animation is playing.
+		if (!force && getCurrentAnimation().startsWith("sing"))
+		{
+			return;
+		}
+
+		// Otherwise, fallback to the super dance() method, which handles playing the idle animation.
+		super.dance();
+	}
+
+	/**
+	 * Returns true if the player just pressed a note.
+	 * Used when determing whether a the player character should revert to the `idle` animation.
+	 * On non-player characters, this should be ignored.
+	 */
+	function justPressedNote(player:Int = 1):Bool
+	{
+		// Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held.
+		switch (player)
+		{
+			case 1:
+				return [
+					PlayerSettings.player1.controls.NOTE_LEFT_P,
+					PlayerSettings.player1.controls.NOTE_DOWN_P,
+					PlayerSettings.player1.controls.NOTE_UP_P,
+					PlayerSettings.player1.controls.NOTE_RIGHT_P,
+				].contains(true);
+			case 2:
+				return [
+					PlayerSettings.player2.controls.NOTE_LEFT_P,
+					PlayerSettings.player2.controls.NOTE_DOWN_P,
+					PlayerSettings.player2.controls.NOTE_UP_P,
+					PlayerSettings.player2.controls.NOTE_RIGHT_P,
+				].contains(true);
+		}
+		return false;
+	}
+
+	/**
+	 * Returns true if the player is holding a note.
+	 * Used when determing whether a the player character should revert to the `idle` animation.
+	 * On non-player characters, this should be ignored.
+	 */
+	function isHoldingNote(player:Int = 1):Bool
+	{
+		// Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held.
+		switch (player)
+		{
+			case 1:
+				return [
+					PlayerSettings.player1.controls.NOTE_LEFT,
+					PlayerSettings.player1.controls.NOTE_DOWN,
+					PlayerSettings.player1.controls.NOTE_UP,
+					PlayerSettings.player1.controls.NOTE_RIGHT,
+				].contains(true);
+			case 2:
+				return [
+					PlayerSettings.player2.controls.NOTE_LEFT,
+					PlayerSettings.player2.controls.NOTE_DOWN,
+					PlayerSettings.player2.controls.NOTE_UP,
+					PlayerSettings.player2.controls.NOTE_RIGHT,
+				].contains(true);
+		}
+		return false;
+	}
+
+	/**
+	 * Every time a note is hit, check if the note is from the same strumline.
+	 * If it is, then play the sing animation.
+	 */
+	public override function onNoteHit(event:NoteScriptEvent)
+	{
+		super.onNoteHit(event);
+
+		trace('HIT NOTE: ${event.note.data.dir} : ${event.note.isSustainNote}');
+
+		holdTimer = 0;
+
+		if (event.note.mustPress && characterType == BF)
+		{
+			// If the note is from the same strumline, play the sing animation.
+			this.playSingAnimation(event.note.data.dir, false, event.note.data.altNote ? "alt" : null);
+		}
+		else if (!event.note.mustPress && characterType == DAD)
+		{
+			// If the note is from the same strumline, play the sing animation.
+			this.playSingAnimation(event.note.data.dir, false, event.note.data.altNote ? "alt" : null);
+		}
+	}
+
+	/**
+	 * Every time a note is missed, check if the note is from the same strumline.
+	 * If it is, then play the sing animation.
+	 */
+	public override function onNoteMiss(event:NoteScriptEvent)
+	{
+		super.onNoteMiss(event);
+
+		if (event.note.mustPress && characterType == BF)
+		{
+			// If the note is from the same strumline, play the sing animation.
+			this.playSingAnimation(event.note.data.dir, true, event.note.data.altNote ? "alt" : null);
+		}
+		else if (!event.note.mustPress && characterType == DAD)
+		{
+			// If the note is from the same strumline, play the sing animation.
+			this.playSingAnimation(event.note.data.dir, true, event.note.data.altNote ? "alt" : null);
+		}
+	}
+
+	/**
+	 * Every time a wrong key is pressed, play the miss animation if we are Boyfriend.
+	 */
+	public override function onNoteGhostMiss(event:GhostMissNoteScriptEvent)
+	{
+		super.onNoteGhostMiss(event);
+
+		if (event.eventCanceled || !event.playAnim)
+		{
+			// Skipping...
+			return;
+		}
+
+		if (characterType == BF)
+		{
+			trace('Playing ghost miss animation...');
+			// If the note is from the same strumline, play the sing animation.
+			this.playSingAnimation(event.dir, true, null);
+		}
+	}
+
+	public override function onDestroy(event:ScriptEvent):Void
+	{
+		this.characterType = OTHER;
+	}
+
+	/**
+	 * Play the appropriate singing animation, for the given note direction.
+	 * @param dir The direction of the note.
+	 * @param miss If true, play the miss animation instead of the sing animation.
+	 * @param suffix A suffix to append to the animation name, like `alt`.
+	 */
+	public function playSingAnimation(dir:NoteDir, miss:Bool = false, suffix:String = ""):Void
+	{
+		var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != "" ? '-${suffix}' : ''}';
+
+		// restart even if already playing, because the character might sing the same note twice.
+		playAnimation(anim, true);
+	}
+}
+
+enum CharacterType
+{
+	BF;
+	DAD;
+	GF;
+	OTHER;
+}
diff --git a/source/funkin/play/character/CharacterBase.hx b/source/funkin/play/character/CharacterBase.hx
deleted file mode 100644
index 22d842fa6..000000000
--- a/source/funkin/play/character/CharacterBase.hx
+++ /dev/null
@@ -1,140 +0,0 @@
-package funkin.play.character;
-
-import funkin.modding.events.ScriptEvent;
-import funkin.modding.events.ScriptEvent.UpdateScriptEvent;
-import funkin.play.character.CharacterData.CharacterDataParser;
-import funkin.Note.NoteDir;
-import funkin.modding.events.ScriptEvent.NoteScriptEvent;
-import funkin.play.stage.Bopper;
-
-/**
- * A Character is a stage prop which bops to the music as well as controlled by the strumlines.
- * 
- * Remember: The character's origin is at its FEET. (horizontal center, vertical bottom)
- */
-class CharacterBase extends Bopper
-{
-	public var characterId(default, null):String;
-	public var characterName(default, null):String;
-
-	/**
-	 * Whether the player is an active character (Boyfriend) or not.
-	 */
-	public var characterType:CharacterType = OTHER;
-
-	public var attachedStrumlines(default, null):Array<Int>;
-
-	final _data:CharacterData;
-
-	/**
-	 * Tracks how long, in seconds, the character has been playing the current `sing` animation.
-	 * This is used to ensure that characters play the `sing` animations for at least one beat,
-	 *   preventing them from reverting to the `idle` animation between notes.
-	 */
-	public var holdTimer:Float = 0;
-
-	final singTimeCrochet:Float;
-
-	public function new(id:String)
-	{
-		super();
-		this.characterId = id;
-		this.attachedStrumlines = [];
-
-		_data = CharacterDataParser.parseCharacterData(this.characterId);
-		if (_data == null)
-		{
-			throw 'Could not find character data for characterId: $characterId';
-		}
-		else
-		{
-			this.characterName = _data.name;
-			this.singTimeCrochet = _data.singTime;
-		}
-	}
-
-	public override function onUpdate(event:UpdateScriptEvent):Void
-	{
-		super.onUpdate(event);
-
-		// Handle character note hold time.
-		holdTimer += event.elapsed;
-		var singTimeMs:Float = singTimeCrochet * Conductor.crochet;
-		// Without this check here, the player character would only play the `sing` animation
-		// for one beat, as opposed to holding it as long as the player is holding the button.
-		var shouldStopSinging:Bool = (this.characterType == BF) ? !isHoldingNote() : true;
-
-		if (holdTimer > singTimeMs && shouldStopSinging)
-		{
-			holdTimer = 0;
-			dance();
-		}
-	}
-
-	/**
-	 * Returns true if the player is holding a note.
-	 * Used when determing whether a the player character should revert to the `idle` animation.
-	 * On non-player characters, this should be ignored.
-	 */
-	function isHoldingNote(player:Int = 1):Bool
-	{
-		// Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held.
-		switch (player)
-		{
-			case 1:
-				return [
-					PlayerSettings.player1.controls.NOTE_LEFT,
-					PlayerSettings.player1.controls.NOTE_DOWN,
-					PlayerSettings.player1.controls.NOTE_UP,
-					PlayerSettings.player1.controls.NOTE_RIGHT,
-				].contains(true);
-			case 2:
-				return [
-					PlayerSettings.player2.controls.NOTE_LEFT,
-					PlayerSettings.player2.controls.NOTE_DOWN,
-					PlayerSettings.player2.controls.NOTE_UP,
-					PlayerSettings.player2.controls.NOTE_RIGHT,
-				].contains(true);
-		}
-		return false;
-	}
-
-	/**
-	 * Every time a note is hit, check if the note is from the same strumline.
-	 * If it is, then play the sing animation.
-	 */
-	public override function onNoteHit(event:NoteScriptEvent)
-	{
-		super.onNoteHit(event);
-		// If event.note is from the same strumline as this character, then sing.
-		// if (this.attachedStrumlines.indexOf(event.note.strumline) != -1)
-		// {
-		//	this.playSingAnimation(event.note.dir, false, note.alt);
-		// }
-	}
-
-	public override function onDestroy(event:ScriptEvent):Void
-	{
-		this.characterType = OTHER;
-	}
-
-	/**
-	 * Play the appropriate singing animation, for the given note direction.
-	 * @param dir The direction of the note.
-	 * @param miss If true, play the miss animation instead of the sing animation.
-	 * @param suffix A suffix to append to the animation name, like `alt`.
-	 */
-	function playSingAnimation(dir:NoteDir, miss:Bool = false, suffix:String = ""):Void
-	{
-		var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != "" ? '-${suffix}' : ''}';
-		playAnimation(anim, true);
-	}
-}
-
-enum CharacterType
-{
-	BF;
-	DAD;
-	GF;
-	OTHER;
-}
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index 6affbc765..87be5f83e 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -1,14 +1,20 @@
 package funkin.play.character;
 
-import openfl.Assets;
-import haxe.Json;
-import funkin.play.character.render.PackerCharacter;
-import funkin.play.character.render.SparrowCharacter;
-import funkin.util.assets.DataAssets;
-import funkin.play.character.CharacterBase;
-import funkin.play.character.ScriptedCharacter.ScriptedSparrowCharacter;
-import funkin.play.character.ScriptedCharacter.ScriptedPackerCharacter;
 import flixel.util.typeLimit.OneOfTwo;
+import funkin.modding.events.ScriptEvent;
+import funkin.modding.events.ScriptEventDispatcher;
+import funkin.play.character.BaseCharacter;
+import funkin.play.character.MultiSparrowCharacter;
+import funkin.play.character.PackerCharacter;
+import funkin.play.character.SparrowCharacter;
+import funkin.play.character.ScriptedCharacter.ScriptedBaseCharacter;
+import funkin.play.character.ScriptedCharacter.ScriptedMultiSparrowCharacter;
+import funkin.play.character.ScriptedCharacter.ScriptedPackerCharacter;
+import funkin.play.character.ScriptedCharacter.ScriptedSparrowCharacter;
+import funkin.util.assets.DataAssets;
+import funkin.util.VersionUtil;
+import haxe.Json;
+import openfl.utils.Assets;
 
 using StringTools;
 
@@ -19,9 +25,15 @@ class CharacterDataParser
 	 * Handle breaking changes by incrementing this value
 	 * and adding migration to the `migrateStageData()` function.
 	 */
-	public static final CHARACTER_DATA_VERSION:String = "1.0";
+	public static final CHARACTER_DATA_VERSION:String = "1.0.0";
 
-	static final characterCache:Map<String, CharacterBase> = new Map<String, CharacterBase>();
+	/**
+	 * 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 DEFAULT_CHAR_ID:String = 'UNKNOWN';
 
@@ -37,73 +49,23 @@ class CharacterDataParser
 		trace("[CHARDATA] Loading character cache...");
 
 		//
-		// SCRIPTED CHARACTERS
-		//
-
-		// Generic (Sparrow) characters
-		var scriptedCharClassNames:Array<String> = ScriptedCharacter.listScriptClasses();
-		trace('  Instantiating ${scriptedCharClassNames.length} scripted characters...');
-		for (charCls in scriptedCharClassNames)
-		{
-			_storeChar(ScriptedCharacter.init(charCls, DEFAULT_CHAR_ID), charCls);
-		}
-
-		// Sparrow characters
-		scriptedCharClassNames = ScriptedSparrowCharacter.listScriptClasses();
-		if (scriptedCharClassNames.length > 0)
-		{
-			trace('  Instantiating ${scriptedCharClassNames.length} scripted characters (SPARROW)...');
-			for (charCls in scriptedCharClassNames)
-			{
-				_storeChar(ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID), charCls);
-			}
-		}
-
-		//		// Packer characters
-		//		scriptedCharClassNames = ScriptedPackerCharacter.listScriptClasses();
-		//		if (scriptedCharClassNames.length > 0)
-		//		{
-		//			trace('  Instantiating ${scriptedCharClassNames.length} scripted characters (PACKER)...');
-		//			for (charCls in scriptedCharClassNames)
-		//			{
-		//				_storeChar(ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID), charCls);
-		//			}
-		//		}
-
-		// TODO: Add more character types.
-
-		//
-		// UNSCRIPTED STAGES
+		// UNSCRIPTED CHARACTERS
 		//
 		var charIdList:Array<String> = DataAssets.listDataFilesInPath('characters/');
 		var unscriptedCharIds:Array<String> = charIdList.filter(function(charId:String):Bool
 		{
 			return !characterCache.exists(charId);
 		});
-		trace('  Instantiating ${unscriptedCharIds.length} non-scripted characters...');
+		trace('  Fetching data for ${unscriptedCharIds.length} characters...');
 		for (charId in unscriptedCharIds)
 		{
-			var char:CharacterBase = null;
 			try
 			{
 				var charData:CharacterData = parseCharacterData(charId);
 				if (charData != null)
 				{
-					switch (charData.renderType)
-					{
-						case CharacterRenderType.PACKER:
-							char = new PackerCharacter(charId);
-						case CharacterRenderType.SPARROW:
-							// default
-							char = new SparrowCharacter(charId);
-						default:
-							trace('    Failed to instantiate character: ${charId} (Bad render type ${charData.renderType})');
-					}
-				}
-				if (char != null)
-				{
-					trace('    Loaded character data: ${char.characterName}');
-					characterCache.set(charId, char);
+					trace('    Loaded character data: ${charId}');
+					characterCache.set(charId, charData);
 				}
 			}
 			catch (e)
@@ -113,39 +75,140 @@ class CharacterDataParser
 			}
 		}
 
+		//
+		// SCRIPTED CHARACTERS
+		//
+
+		// 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 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();
+		trace('  Instantiating ${scriptedCharClassNames3.length} (Multi-Sparrow) scripted characters...');
+		for (charCls in scriptedCharClassNames3)
+		{
+			var character = ScriptedBaseCharacter.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.filter(function(charCls:String):Bool
+		{
+			return !scriptedCharClassNames1.contains(charCls)
+				&& !scriptedCharClassNames2.contains(charCls)
+				&& !scriptedCharClassNames3.contains(charCls);
+		});
+
+		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;
+			}
+			characterScriptedClass.set(character.characterId, charCls);
+		}
+
 		trace('  Successfully loaded ${Lambda.count(characterCache)} stages.');
 	}
 
-	static function _storeChar(char:CharacterBase, charCls:String):Void
+	public static function fetchCharacter(charId:String):Null<BaseCharacter>
 	{
-		if (char != null)
+		if (charId == null || charId == '')
 		{
-			trace('    Loaded scripted character: ${char.characterName}');
-			// Disable the rendering logic for stage until it's loaded.
-			// Note that kill() =/= destroy()
-			char.kill();
+			// Gracefully handle songs that don't use this character.
+			return null;
+		}
 
-			// Then store it.
-			characterCache.set(char.characterId, char);
+		if (characterCache.exists(charId))
+		{
+			var charData:CharacterData = characterCache.get(charId);
+			var charScriptClass:String = characterScriptedClass.get(charId);
+
+			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);
+				}
+			}
+
+			trace('[CHARDATA] Successfully instantiated character: ${charId}');
+
+			// Call onCreate only in the fetchCharacter() function, not at application initialization.
+			ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE));
+
+			return char;
 		}
 		else
 		{
-			trace('    Failed to instantiate scripted character class: ${charCls}');
+			trace('[CHARDATA] Failed to build character, not found in cache: ${charId}');
+			return null;
 		}
 	}
 
-	public static function fetchCharacter(charId:String):Null<CharacterBase>
+	public static function fetchCharacterData(charId:String):Null<CharacterData>
 	{
 		if (characterCache.exists(charId))
 		{
-			trace('[CHARDATA] Successfully fetch stage: ${charId}');
-			var character:CharacterBase = characterCache.get(charId);
-			character.revive();
-			return character;
+			return characterCache.get(charId);
 		}
 		else
 		{
-			trace('[CHARDATA] Failed to fetch character, not found in cache: ${charId}');
 			return null;
 		}
 	}
@@ -154,12 +217,12 @@ class CharacterDataParser
 	{
 		if (characterCache != null)
 		{
-			for (char in characterCache)
-			{
-				char.destroy();
-			}
 			characterCache.clear();
 		}
+		if (characterScriptedClass != null)
+		{
+			characterScriptedClass.clear();
+		}
 	}
 
 	/**
@@ -180,9 +243,9 @@ class CharacterDataParser
 	static function loadCharacterFile(charPath:String):String
 	{
 		var charFilePath:String = Paths.json('characters/${charPath}');
-		var rawJson = Assets.getText(charFilePath).trim();
+		var rawJson = StringTools.trim(Assets.getText(charFilePath));
 
-		while (!rawJson.endsWith("}"))
+		while (!StringTools.endsWith(rawJson, "}"))
 		{
 			rawJson = rawJson.substr(0, rawJson.length - 1);
 		}
@@ -208,18 +271,26 @@ class CharacterDataParser
 		}
 	}
 
-	static final DEFAULT_NAME:String = "Untitled Character";
-	static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW;
-	static final DEFAULT_STARTINGANIM:String = "idle";
-	static final DEFAULT_SCROLL:Array<Float> = [0, 0];
-	static final DEFAULT_ISPIXEL:Bool = false;
+	/**
+	 * 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_FRAMERATE:Int = 24;
 	static final DEFAULT_FLIPX:Bool = false;
-	static final DEFAULT_SCALE:Float = 1;
 	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_FRAMEINDICES:Array<Int> = [];
+	static final DEFAULT_NAME:String = "Untitled Character";
+	static final DEFAULT_OFFSETS:Array<Int> = [0, 0];
+	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.
@@ -238,13 +309,13 @@ class CharacterDataParser
 
 		if (input.version == null)
 		{
-			trace('[CHARDATA] ERROR: Could not load character data for "$id": missing version');
-			return 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 == CHARACTER_DATA_VERSION)
+		if (!VersionUtil.validateVersion(input.version, CHARACTER_DATA_VERSION_RULE))
 		{
-			trace('[CHARDATA] ERROR: Could not load character data for "$id": bad/outdated version (got ${input.version}, expected ${CHARACTER_DATA_VERSION})');
+			trace('[CHARDATA] ERROR: Could not load character data for "$id": bad version (got ${input.version}, expected ${CHARACTER_DATA_VERSION_RULE})');
 			return null;
 		}
 
@@ -285,6 +356,11 @@ class CharacterDataParser
 			input.danceEvery = DEFAULT_DANCEEVERY;
 		}
 
+		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');
@@ -309,9 +385,9 @@ class CharacterDataParser
 				inputAnimation.frameRate = DEFAULT_FRAMERATE;
 			}
 
-			if (inputAnimation.frameIndices == null)
+			if (inputAnimation.offsets == null)
 			{
-				inputAnimation.frameIndices = DEFAULT_FRAMEINDICES;
+				inputAnimation.offsets = DEFAULT_OFFSETS;
 			}
 
 			if (inputAnimation.looped == null)
@@ -339,15 +415,20 @@ enum abstract CharacterRenderType(String) from String to String
 {
 	var SPARROW = 'sparrow';
 	var PACKER = 'packer';
-	// TODO: Aesprite?
+	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?
-	// TODO: Experimental...
+	//   https://lib.haxe.org/p/flxanimate
+	// TODO: REDACTED
 }
 
 typedef CharacterData =
 {
 	/**
-	 * The sematic version of the chart data format.
+	 * The sematic version number of the character data JSON format.
 	 */
 	var version:String;
 
diff --git a/source/funkin/play/character/MultiSparrowCharacter.hx b/source/funkin/play/character/MultiSparrowCharacter.hx
new file mode 100644
index 000000000..22cabccf4
--- /dev/null
+++ b/source/funkin/play/character/MultiSparrowCharacter.hx
@@ -0,0 +1,217 @@
+package funkin.play.character;
+
+import funkin.modding.events.ScriptEvent;
+import funkin.util.assets.FlxAnimationUtil;
+import flixel.graphics.frames.FlxFramesCollection;
+
+/**
+ * For some characters which use Sparrow atlases, the spritesheets need to be split
+ * into multiple files. This character renderer handles by showing the appropriate sprite.
+ * 
+ * Examples in base game include BF Holding GF (most of the sprites are in one file
+ * but the death animation is in a separate file).
+ * Only example I can think of in mods is Tricky (which has a separate file for each animation).
+ * 
+ * BaseCharacter has game logic, SparrowCharacter has only rendering logic.
+ * KEEP THEM SEPARATE!
+ */
+class MultiSparrowCharacter extends BaseCharacter
+{
+	/**
+	 * 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>();
+
+	/**
+	 * The current frame collection being used.
+	 */
+	private var activeMember:String;
+
+	public function new(id:String)
+	{
+		super(id);
+	}
+
+	override function onCreate(event:ScriptEvent):Void
+	{
+		trace('Creating MULTI SPARROW CHARACTER: ' + this.characterId);
+
+		buildSprite();
+
+		playAnimation(_data.startingAnimation);
+	}
+
+	function buildSprite()
+	{
+		buildSpritesheets();
+		buildAnimations();
+
+		if (_data.isPixel)
+		{
+			this.antialiasing = false;
+		}
+		else
+		{
+			this.antialiasing = true;
+		}
+
+		if (_data.scale != null)
+		{
+			this.setGraphicSize(Std.int(this.width * this.scale.x));
+			this.updateHitbox();
+		}
+	}
+
+	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.
+			texture.parent.destroyOnNoUse = false;
+
+			if (texture == null)
+			{
+				trace('Multi-Sparrow atlas could not load texture: ${asset}');
+			}
+			else
+			{
+				trace('Adding multi-sparrow atlas: ${asset}');
+				members.set(asset, texture);
+			}
+		}
+
+		// 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;
+		}
+
+		if (this.activeMember == assetPath)
+		{
+			trace('Already using this asset path: ${assetPath}');
+			return;
+		}
+
+		if (members.exists(assetPath))
+		{
+			trace('Loading frames from asset path: ${assetPath}');
+			this.frames = members.get(assetPath);
+			this.activeMember = assetPath;
+		}
+		else
+		{
+			trace('Multi-Sparrow 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('Multi-Sparrow could not find animation: ${animName}');
+		}
+	}
+
+	function buildAnimations()
+	{
+		trace('[SPARROWCHAR] 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)
+		{
+			trace('Using frames: ${anim.name}');
+			loadFramesByAnimName(anim.name);
+			trace('Adding animation');
+			FlxAnimationUtil.addSparrowAnimation(this, anim);
+
+			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}');
+	}
+
+	public override function playAnimation(name:String, restart:Bool = false):Void
+	{
+		loadFramesByAnimName(name);
+		super.playAnimation(name, restart);
+	}
+
+	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;
+		}
+
+		return this.frames;
+	}
+}
diff --git a/source/funkin/play/character/render/PackerCharacter.hx b/source/funkin/play/character/PackerCharacter.hx
similarity index 53%
rename from source/funkin/play/character/render/PackerCharacter.hx
rename to source/funkin/play/character/PackerCharacter.hx
index cc9e82709..72cf2c7e0 100644
--- a/source/funkin/play/character/render/PackerCharacter.hx
+++ b/source/funkin/play/character/PackerCharacter.hx
@@ -1,15 +1,16 @@
-package funkin.play.character.render;
+package funkin.play.character;
 
-import funkin.play.character.CharacterBase.CharacterType;
+import funkin.play.character.BaseCharacter.CharacterType;
 
 /**
  * A PackerCharacter is a Character which is rendered by
  * displaying an animation derived from a Packer spritesheet file.
  */
-class PackerCharacter extends CharacterBase
+class PackerCharacter extends BaseCharacter
 {
 	public function new(id:String)
 	{
 		super(id);
 	}
+	// TODO: Put code here, dumbass!
 }
diff --git a/source/funkin/play/character/ScriptedCharacter.hx b/source/funkin/play/character/ScriptedCharacter.hx
index b182b283d..1ce8f7f93 100644
--- a/source/funkin/play/character/ScriptedCharacter.hx
+++ b/source/funkin/play/character/ScriptedCharacter.hx
@@ -1,14 +1,23 @@
 package funkin.play.character;
 
-import funkin.play.character.render.PackerCharacter;
-import funkin.play.character.render.SparrowCharacter;
+import funkin.play.character.PackerCharacter;
+import funkin.play.character.SparrowCharacter;
+import funkin.play.character.MultiSparrowCharacter;
 import funkin.modding.IHook;
 
+/**
+ * Note: Making a scripted class extending BaseCharacter is not recommended.
+ * Do so ONLY if are handling all the character rendering yourself,
+ * and can't use one of the built-in render modes.
+ */
 @:hscriptClass
-class ScriptedCharacter extends SparrowCharacter implements IHook {}
+class ScriptedBaseCharacter extends BaseCharacter implements IHook {}
 
 @:hscriptClass
 class ScriptedSparrowCharacter extends SparrowCharacter implements IHook {}
 
+@:hscriptClass
+class ScriptedMultiSparrowCharacter extends MultiSparrowCharacter implements IHook {}
+
 @:hscriptClass
 class ScriptedPackerCharacter extends PackerCharacter implements IHook {}
diff --git a/source/funkin/play/character/SparrowCharacter.hx b/source/funkin/play/character/SparrowCharacter.hx
new file mode 100644
index 000000000..d331b7da2
--- /dev/null
+++ b/source/funkin/play/character/SparrowCharacter.hx
@@ -0,0 +1,81 @@
+package funkin.play.character;
+
+import funkin.modding.events.ScriptEvent;
+import funkin.util.assets.FlxAnimationUtil;
+import flixel.graphics.frames.FlxFramesCollection;
+
+/**
+ * A SparrowCharacter is a Character which is rendered by
+ * displaying an animation derived from a SparrowV2 atlas spritesheet file.
+ * 
+ * BaseCharacter has game logic, SparrowCharacter has only rendering logic.
+ * KEEP THEM SEPARATE!
+ */
+class SparrowCharacter extends BaseCharacter
+{
+	public function new(id:String)
+	{
+		super(id);
+	}
+
+	override function onCreate(event:ScriptEvent):Void
+	{
+		trace('Creating SPARROW CHARACTER: ' + this.characterId);
+
+		loadSpritesheet();
+		loadAnimations();
+
+		playAnimation(_data.startingAnimation);
+	}
+
+	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;
+		}
+
+		this.frames = tex;
+
+		if (_data.isPixel)
+		{
+			this.antialiasing = false;
+		}
+		else
+		{
+			this.antialiasing = true;
+		}
+
+		if (_data.scale != null)
+		{
+			this.setGraphicSize(Std.int(this.width * this.scale.x));
+			this.updateHitbox();
+		}
+	}
+
+	function loadAnimations()
+	{
+		trace('[SPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}');
+
+		FlxAnimationUtil.addSparrowAnimations(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]);
+			}
+		}
+
+		var animNames = this.animation.getNameList();
+		trace('[SPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}');
+	}
+}
diff --git a/source/funkin/play/character/render/SparrowCharacter.hx b/source/funkin/play/character/render/SparrowCharacter.hx
deleted file mode 100644
index 3b2de6e92..000000000
--- a/source/funkin/play/character/render/SparrowCharacter.hx
+++ /dev/null
@@ -1,15 +0,0 @@
-package funkin.play.character.render;
-
-import funkin.play.character.CharacterBase.CharacterType;
-
-/**
- * A SparrowCharacter is a Character which is rendered by
- * displaying an animation derived from a SparrowV2 atlas spritesheet file.
- */
-class SparrowCharacter extends CharacterBase
-{
-	public function new(id:String)
-	{
-		super(id);
-	}
-}
diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index 632c57f3c..6fe111c50 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -30,24 +30,14 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
 	public var shouldAlternate:Null<Bool> = null;
 
 	/**
-	 * Set this value to define an additional horizontal offset to this sprite's position.
+	 * Offset the character's sprite by this much when playing each animation.
 	 */
-	public var xOffset(default, set):Float = 0;
-
-	override function set_x(value:Float):Float
-	{
-		this.x = this.xOffset + value;
-		return this.x;
-	}
-
-	function set_xOffset(value:Float):Float
-	{
-		var diff = value - this.xOffset;
-		this.xOffset = value;
-		this.x += diff;
-		return value;
-	}
+	var animationOffsets:Map<String, Array<Int>> = new Map<String, Array<Int>>();
 
+	/**
+	 * Add a suffix to the `idle` animation (or `danceLeft` and `danceRight` animations)
+	 * that this bopper will play.
+	 */
 	public var idleSuffix(default, set):String = "";
 
 	function set_idleSuffix(value:String):String
@@ -110,7 +100,7 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
 	/**
 	 * Called every `danceEvery` beats of the song.
 	 */
-	function dance():Void
+	function dance(force:Bool = false):Void
 	{
 		if (this.animation == null)
 		{
@@ -142,23 +132,95 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
 
 	public function hasAnimation(id:String):Bool
 	{
+		if (this.animation == null)
+			return false;
+
 		return this.animation.getByName(id) != null;
 	}
 
-	/*
-	 * @param   AnimName   The string name of the animation you want to play.
-	 * @param   Force      Whether to force the animation to restart.
+	/**
+	 * 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 
 	 */
-	public function playAnimation(name:String, force:Bool = false):Void
+	function correctAnimationName(name:String)
 	{
-		this.animation.play(name, force, false, 0);
+		// 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;
+			}
+		}
+	}
+
+	/**
+	 * @param name The name of the animation to play.
+	 * @param restart Whether to restart the animation if it is already playing.
+	 */
+	public function playAnimation(name:String, restart:Bool = false):Void
+	{
+		var correctName = correctAnimationName(name);
+		if (correctName == null)
+			return;
+
+		this.animation.play(correctName, restart, false, 0);
+
+		applyAnimationOffsets(correctName);
+	}
+
+	function applyAnimationOffsets(name:String)
+	{
+		var offsets = animationOffsets.get(name);
+		if (offsets != null)
+		{
+			this.offset.set(offsets[0], offsets[1]);
+		}
+		else
+		{
+			this.offset.set(0, 0);
+		}
+	}
+
+	public function isAnimationFinished():Bool
+	{
+		return this.animation.finished;
+	}
+
+	public function setAnimationOffsets(name:String, xOffset:Int, yOffset:Int):Void
+	{
+		animationOffsets.set(name, [xOffset, yOffset]);
+		applyAnimationOffsets(name);
 	}
 
 	/**
 	 * 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;
 	}
 
@@ -178,16 +240,14 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
 
 	public function onSongEnd(event:ScriptEvent) {}
 
-	public function onSongReset(event:ScriptEvent) {}
-
 	public function onGameOver(event:ScriptEvent) {}
 
-	public function onGameRetry(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) {}
@@ -197,4 +257,6 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
 	public function onCountdownEnd(event:CountdownScriptEvent) {}
 
 	public function onSongLoaded(eent:SongLoadScriptEvent) {}
+
+	public function onSongRetry(event:ScriptEvent) {}
 }
diff --git a/source/funkin/play/stage/ScriptedBopper.hx b/source/funkin/play/stage/ScriptedBopper.hx
index 14e7644da..4dd98e82c 100644
--- a/source/funkin/play/stage/ScriptedBopper.hx
+++ b/source/funkin/play/stage/ScriptedBopper.hx
@@ -2,6 +2,7 @@ package funkin.play.stage;
 
 import funkin.modding.IHook;
 
-@:hscriptClass
-@:keep
-class ScriptedBopper extends Bopper implements IHook {}
+//
+// @:hscriptClass
+// @:keep
+// class ScriptedBopper extends Bopper implements IHook {}
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index 233e02a66..68c5ef865 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -1,6 +1,7 @@
 package funkin.play.stage;
 
-import funkin.play.character.CharacterBase;
+import funkin.util.assets.FlxAnimationUtil;
+import funkin.play.character.BaseCharacter;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEvent.CountdownScriptEvent;
@@ -11,7 +12,7 @@ import flixel.group.FlxSpriteGroup;
 import flixel.math.FlxPoint;
 import flixel.util.FlxSort;
 import funkin.modding.IHook;
-import funkin.play.character.CharacterBase.CharacterType;
+import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.stage.StageData.StageDataParser;
 import funkin.util.SortUtil;
 
@@ -30,7 +31,7 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
 	public var camZoom:Float = 1.0;
 
 	var namedProps:Map<String, FlxSprite> = new Map<String, FlxSprite>();
-	var characters:Map<String, CharacterBase> = new Map<String, CharacterBase>();
+	var characters:Map<String, BaseCharacter> = new Map<String, BaseCharacter>();
 	var charactersOld:Map<String, Character> = new Map<String, Character>();
 	var boppers:Array<Bopper> = new Array<Bopper>();
 
@@ -145,21 +146,22 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
 					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"
-					for (propAnim in dataProp.animations)
-					{
-						if (propAnim.frameIndices.length == 0)
-						{
-							propSprite.animation.addByPrefix(propAnim.name, propAnim.prefix, propAnim.frameRate, propAnim.looped, propAnim.flipX,
-								propAnim.flipY);
-						}
-						else
-						{
-							propSprite.animation.addByIndices(propAnim.name, propAnim.prefix, propAnim.frameIndices, "", propAnim.frameRate, propAnim.looped,
-								propAnim.flipX, propAnim.flipY);
-						}
-					}
+					FlxAnimationUtil.addSparrowAnimations(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 (dataProp.startingAnimation != null)
@@ -236,8 +238,11 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
 	/**
 	 * Used by the PlayState to add a character to the stage.
 	 */
-	public function addCharacter(character:CharacterBase, charType:CharacterType)
+	public function addCharacter(character:BaseCharacter, charType:CharacterType)
 	{
+		if (character == null)
+			return;
+
 		// Apply position and z-index.
 		switch (charType)
 		{
@@ -264,59 +269,63 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
 		this.add(character);
 	}
 
-	/**
-	 * Used by the PlayState to add a character to the stage.
-	 */
-	public function addCharacterOld(character:Character, charType:CharacterType)
+	public inline function getGirlfriendPosition():FlxPoint
 	{
-		// Apply position and z-index.
-		switch (charType)
-		{
-			case BF:
-				this.charactersOld.set("bf", character);
-				character.zIndex = _data.characters.bf.zIndex;
-				character.x = _data.characters.bf.position[0];
-				character.y = _data.characters.bf.position[1];
-			case GF:
-				this.charactersOld.set("gf", character);
-				character.zIndex = _data.characters.gf.zIndex;
-				character.x = _data.characters.gf.position[0];
-				character.y = _data.characters.gf.position[1];
-			case DAD:
-				this.charactersOld.set("dad", character);
-				character.zIndex = _data.characters.dad.zIndex;
-				character.x = _data.characters.dad.position[0];
-				character.y = _data.characters.dad.position[1];
-			default:
-				this.charactersOld.set(character.curCharacter, character);
-		}
+		return new FlxPoint(_data.characters.gf.position[0], _data.characters.gf.position[1]);
+	}
 
-		// Add the character to the scene.
-		this.add(character);
+	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):CharacterBase
+	public function getCharacter(id:String):BaseCharacter
 	{
 		return this.characters.get(id);
 	}
 
-	public function getBoyfriend():CharacterBase
+	/**
+	 * 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
 	{
-		return getCharacter('bf');
+		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');
+		}
+
 		// return this.charactersOld.get('bf');
 	}
 
-	public function getGirlfriend():Character
+	public function getGirlfriend():BaseCharacter
 	{
-		return this.charactersOld.get('gf');
+		return getCharacter('gf');
+		// return this.charactersOld.get('gf');
 	}
 
-	public function getDad():Character
+	public function getDad():BaseCharacter
 	{
-		return this.charactersOld.get('dad');
+		return getCharacter('dad');
+		// return this.charactersOld.get('dad');
 	}
 
 	/**
@@ -345,6 +354,32 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
 		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.
@@ -419,15 +454,8 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
 
 	public function onSongEnd(event:ScriptEvent) {}
 
-	/**
-	 * Resets the stage and its props.
-	 */
-	public function onSongReset(event:ScriptEvent) {}
-
 	public function onGameOver(event:ScriptEvent) {}
 
-	public function onGameRetry(event:ScriptEvent) {}
-
 	public function onCountdownStart(event:CountdownScriptEvent) {}
 
 	public function onCountdownStep(event:CountdownScriptEvent) {}
@@ -443,5 +471,9 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
 
 	public function onNoteMiss(event:NoteScriptEvent) {}
 
+	public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {}
+
 	public function onSongLoaded(eent:SongLoadScriptEvent) {}
+
+	public function onSongRetry(event:ScriptEvent) {}
 }
diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx
index bc9946509..475f4ad9c 100644
--- a/source/funkin/play/stage/StageData.hx
+++ b/source/funkin/play/stage/StageData.hx
@@ -1,5 +1,6 @@
 package funkin.play.stage;
 
+import funkin.util.VersionUtil;
 import flixel.util.typeLimit.OneOfTwo;
 import funkin.util.assets.DataAssets;
 import haxe.Json;
@@ -17,7 +18,12 @@ class StageDataParser
 	 * Handle breaking changes by incrementing this value
 	 * and adding migration to the `migrateStageData()` function.
 	 */
-	public static final STAGE_DATA_VERSION:String = "1.0";
+	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";
 
 	static final stageCache:Map<String, Stage> = new Map<String, Stage>();
 
@@ -163,16 +169,16 @@ class StageDataParser
 		}
 	}
 
-	static final DEFAULT_NAME:String = "Untitled Stage";
-	static final DEFAULT_CAMERAZOOM:Float = 1.0;
-	static final DEFAULT_ZINDEX:Int = 0;
-	static final DEFAULT_DANCEEVERY:Int = 0;
-	static final DEFAULT_SCALE:Float = 1.0;
-	static final DEFAULT_ISPIXEL:Bool = false;
-	static final DEFAULT_POSITION:Array<Float> = [0, 0];
-	static final DEFAULT_SCROLL:Array<Float> = [0, 0];
-	static final DEFAULT_FRAMEINDICES:Array<Int> = [];
 	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<Int> = [0, 0];
+	static final DEFAULT_POSITION:Array<Float> = [0, 0];
+	static final DEFAULT_SCALE: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,
@@ -200,9 +206,9 @@ class StageDataParser
 			return null;
 		}
 
-		if (input.version != STAGE_DATA_VERSION)
+		if (!VersionUtil.validateVersion(input.version, STAGE_DATA_VERSION_RULE))
 		{
-			trace('[STAGEDATA] ERROR: Could not load stage data for "$id": bad/outdated version (got ${input.version}, expected ${STAGE_DATA_VERSION})');
+			trace('[STAGEDATA] ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})');
 			return null;
 		}
 
@@ -302,9 +308,9 @@ class StageDataParser
 					inputAnimation.frameRate = 24;
 				}
 
-				if (inputAnimation.frameIndices == null)
+				if (inputAnimation.offsets == null)
 				{
-					inputAnimation.frameIndices = DEFAULT_FRAMEINDICES;
+					inputAnimation.offsets = DEFAULT_OFFSETS;
 				}
 
 				if (inputAnimation.looped == null)
@@ -362,8 +368,12 @@ class StageDataParser
 
 typedef StageData =
 {
-	// Uses semantic versioning.
+	/**
+	 * 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>;
diff --git a/source/funkin/ui/AtlasText.hx b/source/funkin/ui/AtlasText.hx
index 6df5f9e22..8eb0d041d 100644
--- a/source/funkin/ui/AtlasText.hx
+++ b/source/funkin/ui/AtlasText.hx
@@ -15,7 +15,8 @@ abstract BoldText(AtlasText) from AtlasText to AtlasText
 }
 
 /**
- * Alphabet.hx has a ton of bugs and does a bunch of stuff I don't need, fuck that class
+ * AtlasText is an improved version of Alphabet and FlxBitmapText.
+ * It supports animations on the letters, and is less buggy than Alphabet.
  */
 class AtlasText extends FlxTypedSpriteGroup<AtlasChar>
 {
diff --git a/source/funkin/ui/OptionsState.hx b/source/funkin/ui/OptionsState.hx
index 1b6d03e93..fc5c928c2 100644
--- a/source/funkin/ui/OptionsState.hx
+++ b/source/funkin/ui/OptionsState.hx
@@ -5,7 +5,6 @@ import flixel.FlxSubState;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.group.FlxGroup;
 import flixel.util.FlxSignal;
-import funkin.i18n.FireTongueHandler.t;
 
 // typedef OptionsState = OptionsMenu_old;
 // class OptionsState_new extends MusicBeatState
@@ -172,25 +171,25 @@ class OptionsMenu extends Page
 		super();
 
 		add(items = new TextMenuList());
-		createItem(t("PREFERENCES"), function() switchPage(Preferences));
-		createItem(t("CONTROLS"), function() switchPage(Controls));
-		// createItem(t("COLORS"), function() switchPage(Colors));
-		createItem(t("MODS"), function() switchPage(Mods));
+		createItem("PREFERENCES", function() switchPage(Preferences));
+		createItem("CONTROLS", function() switchPage(Controls));
+		// createItem("COLORS", function() switchPage(Colors));
+		createItem("MODS", function() switchPage(Mods));
 
 		#if CAN_OPEN_LINKS
 		if (showDonate)
 		{
 			var hasPopupBlocker = #if web true #else false #end;
-			createItem(t("DONATE"), selectDonate, hasPopupBlocker);
+			createItem("DONATE", selectDonate, hasPopupBlocker);
 		}
 		#end
 		#if newgrounds
 		if (NGio.isLoggedIn)
-			createItem(t("LOGOUT"), selectLogout);
+			createItem("LOGOUT", selectLogout);
 		else
-			createItem(t("LOGIN"), selectLogin);
+			createItem("LOGIN", selectLogin);
 		#end
-		createItem(t("EXIT"), exit);
+		createItem("EXIT", exit);
 	}
 
 	function createItem(name:String, callback:Void->Void, fireInstantly = false)
diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx
new file mode 100644
index 000000000..a5c7e71f4
--- /dev/null
+++ b/source/funkin/util/VersionUtil.hx
@@ -0,0 +1,31 @@
+package funkin.util;
+
+import thx.semver.VersionRule;
+import thx.semver.Version;
+
+/**
+ * Remember, increment the patch version (1.0.x) if you make a bugfix,
+ * increment the minor version (1.x.0) if you make a new feature (but previous content is still compatible),
+ * and increment the major version (x.0.0) if you make a breaking change (e.g. new API or reorganized file format).
+ */
+class VersionUtil
+{
+	/**
+	 * Checks that a given verison number satisisfies a given version rule.
+	 * Version rule can be complex, e.g. "1.0.x" or ">=1.0.0,<1.1.0", or anything NPM supports.
+	 */
+	public static function validateVersion(version:String, versionRule:String):Bool
+	{
+		try
+		{
+			var v:Version = version; // Perform a cast.
+			var vr:VersionRule = versionRule; // Perform a cast.
+			return v.satisfies(vr);
+		}
+		catch (e)
+		{
+			trace('[VERSIONUTIL] Invalid semantic version: ${version}');
+			return false;
+		}
+	}
+}
diff --git a/source/funkin/util/assets/DataAssets.hx b/source/funkin/util/assets/DataAssets.hx
index d00703068..ed4805276 100644
--- a/source/funkin/util/assets/DataAssets.hx
+++ b/source/funkin/util/assets/DataAssets.hx
@@ -21,7 +21,10 @@ class DataAssets
 			{
 				var pathNoSuffix = textPath.substring(0, textPath.length - ext.length);
 				var pathNoPrefix = pathNoSuffix.substring(queryPath.length);
-				results.push(pathNoPrefix);
+
+				// No duplicates! Why does this happen?
+				if (!results.contains(pathNoPrefix))
+					results.push(pathNoPrefix);
 			}
 		}
 
diff --git a/source/funkin/util/assets/FlxAnimationUtil.hx b/source/funkin/util/assets/FlxAnimationUtil.hx
new file mode 100644
index 000000000..0f3a35728
--- /dev/null
+++ b/source/funkin/util/assets/FlxAnimationUtil.hx
@@ -0,0 +1,42 @@
+package funkin.util.assets;
+
+import funkin.play.AnimationData;
+import flixel.FlxSprite;
+
+class FlxAnimationUtil
+{
+	/**
+	 * Properly adds an animation to a sprite based on JSON data.
+	 */
+	public static function addSparrowAnimation(target:FlxSprite, anim:AnimationData)
+	{
+		var frameRate = anim.frameRate == null ? 24 : anim.frameRate;
+		var looped = anim.looped == null ? false : anim.looped;
+		var flipX = anim.flipX == null ? false : anim.flipX;
+		var flipY = anim.flipY == null ? false : anim.flipY;
+
+		if (anim.frameIndices != null && anim.frameIndices.length > 0)
+		{
+			// trace('addByIndices(${anim.name}, ${anim.prefix}, ${anim.frameIndices}, ${frameRate}, ${looped}, ${flipX}, ${flipY})');
+			target.animation.addByIndices(anim.name, anim.prefix, anim.frameIndices, "", frameRate, looped, flipX, flipY);
+			// trace('RESULT:${target.animation.getAnimationList()}');
+		}
+		else
+		{
+			// trace('addByPrefix(${anim.name}, ${anim.prefix}, ${frameRate}, ${looped}, ${flipX}, ${flipY})');
+			target.animation.addByPrefix(anim.name, anim.prefix, frameRate, looped, flipX, flipY);
+			// trace('RESULT:${target.animation.getAnimationList()}');
+		}
+	}
+
+	/**
+	 * Properly adds multiple animations to a sprite based on JSON data.
+	 */
+	public static function addSparrowAnimations(target:FlxSprite, animations:Array<AnimationData>)
+	{
+		for (anim in animations)
+		{
+			addSparrowAnimation(target, anim);
+		}
+	}
+}