diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index eeffebdb1..6c465be9c 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -24,6 +24,7 @@ import funkin.play.song.SongData.SongDataParser;
 import funkin.play.stage.StageData.StageDataParser;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.modding.module.ModuleHandler;
+import funkin.ui.title.TitleState;
 #if discord_rpc
 import Discord.DiscordClient;
 #end
diff --git a/source/funkin/MainMenuState.hx b/source/funkin/MainMenuState.hx
index fc493ef4b..0f594ffd9 100644
--- a/source/funkin/MainMenuState.hx
+++ b/source/funkin/MainMenuState.hx
@@ -22,6 +22,7 @@ import funkin.shaderslmfao.ScreenWipeShader;
 import funkin.ui.AtlasMenuList;
 import funkin.ui.MenuList.MenuItem;
 import funkin.ui.MenuList;
+import funkin.ui.title.TitleState;
 import funkin.ui.story.StoryMenuState;
 import funkin.ui.OptionsState;
 import funkin.ui.PreferencesMenu;
diff --git a/source/funkin/VideoState.hx b/source/funkin/VideoState.hx
deleted file mode 100644
index a169ce5af..000000000
--- a/source/funkin/VideoState.hx
+++ /dev/null
@@ -1,100 +0,0 @@
-package funkin;
-
-import openfl.display.Sprite;
-import openfl.events.AsyncErrorEvent;
-import openfl.events.MouseEvent;
-import openfl.events.NetStatusEvent;
-import openfl.media.Video;
-import openfl.net.NetConnection;
-import openfl.net.NetStream;
-
-class VideoState extends MusicBeatState
-{
-  var video:Video;
-  var netStream:NetStream;
-  var overlay:Sprite;
-
-  public static var seenVideo:Bool = false;
-
-  override function create()
-  {
-    super.create();
-
-    seenVideo = true;
-
-    FlxG.save.data.seenVideo = true;
-    FlxG.save.flush();
-
-    if (FlxG.sound.music != null) FlxG.sound.music.stop();
-
-    video = new Video();
-    FlxG.addChildBelowMouse(video);
-
-    var netConnection = new NetConnection();
-    netConnection.connect(null);
-
-    netStream = new NetStream(netConnection);
-    netStream.client = {onMetaData: client_onMetaData};
-    netStream.addEventListener(AsyncErrorEvent.ASYNC_ERROR, netStream_onAsyncError);
-    netConnection.addEventListener(NetStatusEvent.NET_STATUS, netConnection_onNetStatus);
-    // netStream.addEventListener(NetStatusEvent.NET_STATUS);
-    netStream.play(Paths.file('music/kickstarterTrailer.mp4'));
-
-    overlay = new Sprite();
-    overlay.graphics.beginFill(0, 0.5);
-    overlay.graphics.drawRect(0, 0, 1280, 720);
-    overlay.addEventListener(MouseEvent.MOUSE_DOWN, overlay_onMouseDown);
-
-    overlay.buttonMode = true;
-    // FlxG.stage.addChild(overlay);
-  }
-
-  override function update(elapsed:Float)
-  {
-    if (controls.ACCEPT) finishVid();
-
-    super.update(elapsed);
-  }
-
-  function finishVid():Void
-  {
-    netStream.dispose();
-    FlxG.removeChild(video);
-
-    TitleState.initialized = false;
-    FlxG.switchState(new TitleState());
-  }
-
-  function client_onMetaData(metaData:Dynamic)
-  {
-    video.attachNetStream(netStream);
-
-    video.width = video.videoWidth;
-    video.height = video.videoHeight;
-    // video.
-  }
-
-  function netStream_onAsyncError(event:AsyncErrorEvent):Void
-  {
-    trace("Error loading video");
-  }
-
-  function netConnection_onNetStatus(event:NetStatusEvent):Void
-  {
-    if (event.info.code == 'NetStream.Play.Complete')
-    {
-      finishVid();
-    }
-
-    trace(event.toString());
-  }
-
-  function overlay_onMouseDown(event:MouseEvent):Void
-  {
-    netStream.soundTransform.volume = 0.2;
-    netStream.soundTransform.pan = -1;
-    // netStream.play(Paths.file('music/kickstarterTrailer.mp4'));
-
-    FlxG.stage.removeChild(overlay);
-  }
-}
diff --git a/source/funkin/graphics/video/FlxVideo.hx b/source/funkin/graphics/video/FlxVideo.hx
index 393e2d49c..e95d7caa6 100644
--- a/source/funkin/graphics/video/FlxVideo.hx
+++ b/source/funkin/graphics/video/FlxVideo.hx
@@ -9,6 +9,7 @@ import openfl.net.NetStream;
 
 /**
  * Plays a video via a NetStream. Only works on HTML5.
+ * This does NOT replace hxCodec, nor does hxCodec replace this. hxCodec only works on desktop and does not work on HTML5!
  */
 class FlxVideo extends FlxBasic
 {
diff --git a/source/funkin/ui/title/AttractState.hx b/source/funkin/ui/title/AttractState.hx
new file mode 100644
index 000000000..67c762da4
--- /dev/null
+++ b/source/funkin/ui/title/AttractState.hx
@@ -0,0 +1,109 @@
+package funkin.ui.title;
+
+#if html5
+import funkin.graphics.video.FlxVideo;
+#else
+import hxcodec.flixel.FlxVideoSprite;
+#end
+
+/**
+ * After about 2 minutes of inactivity on the title screen,
+ * the game will enter the Attract state, as a reference to physical arcade machines.
+ *
+ * In the current version, this just plays the Kickstarter trailer, but this can be changed to
+ * gameplay footage, a generic game trailer, or something more elaborate.
+ */
+class AttractState extends MusicBeatState
+{
+  static final ATTRACT_VIDEO_PATH:String = Paths.videos('kickstarterTrailer');
+
+  public override function create():Void
+  {
+    // Pause existing music.
+    FlxG.sound.music.stop();
+
+    #if html5
+    playVideoHTML5(ATTRACT_VIDEO_PATH);
+    #else
+    playVideoNative(ATTRACT_VIDEO_PATH);
+    #end
+  }
+
+  #if html5
+  var vid:FlxVideo;
+
+  function playVideoHTML5(filePath:String):Void
+  {
+    // Video displays OVER the FlxState.
+    vid = new FlxVideo(filePath);
+    if (vid != null)
+    {
+      vid.zIndex = 0;
+
+      vid.finishCallback = onAttractEnd;
+
+      add(vid);
+    }
+    else
+    {
+      trace('ALERT: Video is null! Could not play cutscene!');
+    }
+  }
+  #else
+  var vid:FlxVideoSprite;
+
+  function playVideoNative(filePath:String):Void
+  {
+    // Video displays OVER the FlxState.
+    vid = new FlxVideoSprite(0, 0);
+
+    if (vid != null)
+    {
+      vid.zIndex = 0;
+      vid.bitmap.onEndReached.add(onAttractEnd);
+
+      add(vid);
+      vid.play(filePath, false);
+    }
+    else
+    {
+      trace('ALERT: Video is null! Could not play cutscene!');
+    }
+  }
+  #end
+
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    // If the user presses any button, skip the video.
+    if (FlxG.keys.justPressed.ANY)
+    {
+      onAttractEnd();
+    }
+  }
+
+  /**
+   * When the attraction state ends (after the video ends or the user presses any button),
+   * switch immediately to the title screen.
+   */
+  function onAttractEnd():Void
+  {
+    #if html5
+    if (vid != null)
+    {
+      remove(vid);
+    }
+    #else
+    if (vid != null)
+    {
+      vid.stop();
+      remove(vid);
+    }
+    #end
+    vid.destroy();
+    vid = null;
+
+    FlxG.switchState(new TitleState());
+  }
+}
diff --git a/source/funkin/TitleState.hx b/source/funkin/ui/title/TitleState.hx
similarity index 90%
rename from source/funkin/TitleState.hx
rename to source/funkin/ui/title/TitleState.hx
index 47cc33a38..8b832f789 100644
--- a/source/funkin/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -1,4 +1,4 @@
-package funkin;
+package funkin.ui.title;
 
 import flixel.FlxSprite;
 import flixel.FlxState;
@@ -28,6 +28,9 @@ import openfl.net.NetStream;
 #end
 class TitleState extends MusicBeatState
 {
+  /**
+   * Only play the credits once per session.
+   */
   public static var initialized:Bool = false;
 
   var blackScreen:FlxSprite;
@@ -148,14 +151,20 @@ class TitleState extends MusicBeatState
     // titleText.screenCenter(X);
     add(titleText);
 
-    credGroup = new FlxGroup();
-    add(credGroup);
+    if (!initialized) // Fix an issue where returning to the credits would play a black screen.
+    {
+      credGroup = new FlxGroup();
+      add(credGroup);
+    }
 
     textGroup = new FlxGroup();
 
     blackScreen = bg.clone();
-    credGroup.add(blackScreen);
-    credGroup.add(textGroup);
+    if (credGroup != null)
+    {
+      credGroup.add(blackScreen);
+      credGroup.add(textGroup);
+    }
 
     // var atlasBullShit:FlxSprite = new FlxSprite();
     // atlasBullShit.frames = CoolUtil.fromAnimate(Paths.image('money'), Paths.file('images/money.json'));
@@ -192,7 +201,15 @@ class TitleState extends MusicBeatState
     else
       initialized = true;
 
-    if (FlxG.sound.music != null) FlxG.sound.music.onComplete = function() FlxG.switchState(new VideoState());
+    if (FlxG.sound.music != null) FlxG.sound.music.onComplete = moveToAttract;
+  }
+
+  /**
+   * After sitting on the title screen for a while, transition to the attract screen.
+   */
+  function moveToAttract():Void
+  {
+    FlxG.switchState(new AttractState());
   }
 
   function playMenuMusic():Void
@@ -284,7 +301,7 @@ class TitleState extends MusicBeatState
       #end
     }
 
-    // a faster intro thing lol!
+    // If you spam Enter, we should skip the transition.
     if (pressedEnter && transitioning && skippedIntro)
     {
       FlxG.switchState(new MainMenuState());
@@ -304,50 +321,19 @@ class TitleState extends MusicBeatState
 
       var targetState:FlxState = new MainMenuState();
 
-      #if newgrounds
-      if (!OutdatedSubState.leftState)
-      {
-        NGio.checkVersion(function(version) {
-          // Check if version is outdated
-          var localVersion:String = "v" + Application.current.meta.get('version');
-          var onlineVersion = version.split(" ")[0].trim();
-          if (version.trim() != onlineVersion)
-          {
-            trace('OLD VERSION!');
-            // targetState = new OutdatedSubState();
-          }
-          else
-          {
-            // targetState = new MainMenuState();
-          }
-          // REDO FOR ITCH/FINAL SHIT
-        });
-      }
-      #end
       new FlxTimer().start(2, function(tmr:FlxTimer) {
         // These assets are very unlikely to be used for the rest of gameplay, so it unloads them from cache/memory
         // Saves about 50mb of RAM or so???
-        Assets.cache.clear(Paths.image('gfDanceTitle'));
-        Assets.cache.clear(Paths.image('logoBumpin'));
-        Assets.cache.clear(Paths.image('titleEnter'));
+        // TODO: This BREAKS the title screen if you return back to it! Figure out how to fix that.
+        // Assets.cache.clear(Paths.image('gfDanceTitle'));
+        // Assets.cache.clear(Paths.image('logoBumpin'));
+        // Assets.cache.clear(Paths.image('titleEnter'));
         // ngSpr??
         FlxG.switchState(targetState);
       });
       // FlxG.sound.play(Paths.music('titleShoot'), 0.7);
     }
     if (pressedEnter && !skippedIntro && initialized) skipIntro();
-    /*
-          #if web
-          if (!initialized && controls.ACCEPT)
-          {
-      // netStream.dispose();
-      // FlxG.stage.removeChild(video);
-
-      startIntro();
-      skipIntro();
-          }
-          #end
-     */
 
     if (controls.UI_LEFT) swagShader.update(-elapsed * 0.1);
     if (controls.UI_RIGHT) swagShader.update(elapsed * 0.1);
@@ -358,12 +344,6 @@ class TitleState extends MusicBeatState
   override function draw()
   {
     super.draw();
-
-    // if (gfDance != null)
-    // {
-    // 	trace(gfDance.frame.uv);
-    // 	maskShader.frameUV = gfDance.frame.uv;
-    // }
   }
 
   var cheatArray:Array<Int> = [0x0001, 0x0010, 0x0001, 0x0010, 0x0100, 0x1000, 0x0100, 0x1000];
@@ -523,7 +503,7 @@ class TitleState extends MusicBeatState
     {
       remove(ngSpr);
 
-      FlxG.camera.flash(FlxColor.WHITE, 4);
+      FlxG.camera.flash(FlxColor.WHITE, initialized ? 1 : 4);
       remove(credGroup);
       skippedIntro = true;
     }