diff --git a/README.md b/README.md
index 8f919d231..39c098af5 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ This game was made with love to Newgrounds and it's community. Extra love to Tom
 
 **PLEASE USE THE LINKS ABOVE IF YOU JUST WANT TO PLAY THE GAME**
 
-To learn how to install the necessary dependencies and compile the game from source, please check out our [building the game]() guide.
+To learn how to install the necessary dependencies and compile the game from source, please check out our [building the game](/docs/compiling.md) guide.
 
 # Contributing
 
diff --git a/assets b/assets
index 160acbd8a..599169f47 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 160acbd8a854a9f677ef7587396340e79a5ea6ca
+Subproject commit 599169f4786d1feb62a089d25112035977647805
diff --git a/docs/COMPILING.md b/docs/COMPILING.md
new file mode 100644
index 000000000..7f9c0cdb8
--- /dev/null
+++ b/docs/COMPILING.md
@@ -0,0 +1,19 @@
+# Compiling Friday Night Funkin'
+
+0. Setup
+    - Download Haxe from [Haxe.org](https://haxe.org)
+1. Cloning the Repository: Make sure when you clone, you clone the submodules to get the assets repo:
+    - `git clone --recurse-submodules https://github.com/FunkinCrew/funkin-secret.git`
+    - If you accidentally cloned without the `assets` submodule (aka didn't follow the step above), you can run `git submodule update --init --recursive` to get the assets in a foolproof way.
+2. Install `hmm` (run `haxelib --global install hmm` and then `haxelib --global run hmm setup`)
+3. Install all haxelibs of the current branch by running `hmm install`
+4. Platform setup
+   - For Windows, download the [Visual Studio Build Tools](https://aka.ms/vs/17/release/vs_BuildTools.exe)
+        - When prompted, select "Individual Components" and make sure to download the following:
+        - MSVC v143 VS 2022 C++ x64/x86 build tools
+        - Windows 10/11 SDK
+    - Mac: [`lime setup mac` Documentation](https://lime.openfl.org/docs/advanced-setup/macos/)
+    - Linux: [`lime setup linux` Documentation](https://lime.openfl.org/docs/advanced-setup/linux/)
+    - HTML5: Compiles without any extra setup
+5. If you are targeting for native, you likely need to run `lime rebuild PLATFORM` and `lime rebuild PLATFORM -debug`
+6. `lime test PLATFORM` !
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 27208689b..3b93bab64 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -10,3 +10,6 @@
 
 - `Class lists not properly generated. Try cleaning out your export folder, restarting your IDE, and rebuilding your project.`
   - This is a bug specific to HTML5. Simply perform the steps listed (don't forget to restart the IDE too).
+
+- `LINK : fatal error LNK1201: error writing to program database ''; check for insufficient disk space, invalid path, or insufficient privilege`
+  - This error occurs if the PDB file located in your `export` folder is in use or exceeds 4 GB. Try deleting the `export` folder and building again from scratch.
diff --git a/hmm.json b/hmm.json
index b529a520d..e1acf6c87 100644
--- a/hmm.json
+++ b/hmm.json
@@ -11,7 +11,7 @@
       "name": "flixel",
       "type": "git",
       "dir": null,
-      "ref": "25c84b29665329f7c6366342542a3978f29300ee",
+      "ref": "4d054bd10b05bb1309a0ba3427ffa5378e0b4b99",
       "url": "https://github.com/FunkinCrew/flixel"
     },
     {
diff --git a/source/funkin/graphics/FunkinCamera.hx b/source/funkin/graphics/FunkinCamera.hx
new file mode 100644
index 000000000..f80e799ea
--- /dev/null
+++ b/source/funkin/graphics/FunkinCamera.hx
@@ -0,0 +1,257 @@
+package funkin.graphics;
+
+import flash.geom.ColorTransform;
+import flixel.FlxCamera;
+import flixel.graphics.FlxGraphic;
+import flixel.graphics.frames.FlxFrame;
+import flixel.math.FlxMatrix;
+import flixel.math.FlxRect;
+import flixel.system.FlxAssets.FlxShader;
+import funkin.graphics.shaders.RuntimeCustomBlendShader;
+import funkin.graphics.framebuffer.BitmapDataUtil;
+import funkin.graphics.framebuffer.FixedBitmapData;
+import openfl.Lib;
+import openfl.display.BitmapData;
+import openfl.display.BlendMode;
+import openfl.display3D.textures.TextureBase;
+import openfl.filters.BitmapFilter;
+import openfl.filters.ShaderFilter;
+
+/**
+ * A FlxCamera with additional powerful features:
+ * - Grab the camera screen as a `BitmapData` and use it as a texture
+ * - Support `sprite.blend = DARKEN/HARDLIGHT/LIGHTEN/OVERLAY` to apply visual effects using certain sprites
+ *   - NOTE: Several other blend modes work without FunkinCamera. Some still do not work.
+ * - NOTE: Framerate-independent camera tweening is fixed in Flixel 6.x. Rest in peace, SwagCamera.
+ */
+@:access(openfl.display.DisplayObject)
+@:access(openfl.display.BitmapData)
+@:access(openfl.display3D.Context3D)
+@:access(openfl.display3D.textures.TextureBase)
+@:access(flixel.graphics.FlxGraphic)
+@:access(flixel.graphics.frames.FlxFrame)
+class FunkinCamera extends FlxCamera
+{
+  final grabbed:Array<BitmapData> = [];
+  final texturePool:Array<TextureBase> = [];
+
+  final bgTexture:TextureBase;
+  final bgBitmap:BitmapData;
+  final bgFrame:FlxFrame;
+
+  final customBlendShader:RuntimeCustomBlendShader;
+  final customBlendFilter:ShaderFilter;
+
+  var filtersApplied:Bool = false;
+  var bgItemCount:Int = 0;
+
+  public var shouldDraw:Bool = true;
+
+  public function new(x:Int = 0, y:Int = 0, width:Int = 0, height:Int = 0, zoom:Float = 0)
+  {
+    super(x, y, width, height, zoom);
+    bgTexture = pickTexture(width, height);
+    bgBitmap = FixedBitmapData.fromTexture(bgTexture);
+    bgFrame = new FlxFrame(new FlxGraphic('', null));
+    bgFrame.parent.bitmap = bgBitmap;
+    bgFrame.frame = new FlxRect();
+    customBlendShader = new RuntimeCustomBlendShader();
+    customBlendFilter = new ShaderFilter(customBlendShader);
+  }
+
+  /**
+   * Grabs the camera screen and returns it as a `BitmapData`. The returned bitmap
+   * will not be referred by the camera so, changing it will not affect the scene.
+   * The returned bitmap **will be reused in the next frame**, so the content is available
+   * only in the current frame.
+   * @param applyFilters if this is `true`, the camera's filters will be applied to the grabbed bitmap,
+   * and the camera's filters will be disabled until the beginning of the next frame
+   * @param isolate if this is `true`, sprites to be rendered will only be rendered to the grabbed bitmap,
+   * and the grabbed bitmap will not include any previously rendered sprites
+   * @return the grabbed bitmap data
+   */
+  public function grabScreen(applyFilters:Bool, isolate:Bool = false):BitmapData
+  {
+    final texture = pickTexture(width, height);
+    final bitmap = FixedBitmapData.fromTexture(texture);
+    squashTo(bitmap, applyFilters, isolate);
+    grabbed.push(bitmap);
+    return bitmap;
+  }
+
+  /**
+   * Applies the filter immediately to the camera. This will be done independently from
+   * the camera's filters. This method can only be called after the first `grabScreen`
+   * in the frame.
+   * @param filter the filter
+   */
+  public function applyFilter(filter:BitmapFilter):Void
+  {
+    if (grabbed.length == 0)
+    {
+      FlxG.log.error('grab screen before you can apply a filter!');
+      return;
+    }
+    BitmapDataUtil.applyFilter(bgBitmap, filter);
+  }
+
+  function squashTo(bitmap:BitmapData, applyFilters:Bool, isolate:Bool, clearScreen:Bool = false):Void
+  {
+    if (applyFilters && isolate)
+    {
+      FlxG.log.error('cannot apply filters while isolating!');
+    }
+    if (filtersApplied && applyFilters)
+    {
+      FlxG.log.warn('filters already applied!');
+    }
+    static final matrix = new FlxMatrix();
+
+    // resize the background bitmap if needed
+    if (bgTexture.__width != width || bgTexture.__height != height)
+    {
+      BitmapDataUtil.resizeTexture(bgTexture, width, height);
+      bgBitmap.__resize(width, height);
+      bgFrame.parent.bitmap = bgBitmap;
+    }
+
+    // grab the bitmap
+    renderSkipping(isolate ? bgItemCount : 0);
+    bitmap.fillRect(bitmap.rect, 0);
+    matrix.setTo(1, 0, 0, 1, flashSprite.x, flashSprite.y);
+    if (applyFilters)
+    {
+      bitmap.draw(flashSprite, matrix);
+      flashSprite.filters = null;
+      filtersApplied = true;
+    }
+    else
+    {
+      final tmp = flashSprite.filters;
+      flashSprite.filters = null;
+      bitmap.draw(flashSprite, matrix);
+      flashSprite.filters = tmp;
+    }
+
+    if (!isolate)
+    {
+      // also copy to the background bitmap
+      bgBitmap.fillRect(bgBitmap.rect, 0);
+      bgBitmap.draw(bitmap);
+    }
+
+    if (clearScreen)
+    {
+      // clear graphics data
+      super.clearDrawStack();
+      canvas.graphics.clear();
+    }
+
+    // render the background bitmap
+    bgFrame.frame.set(0, 0, width, height);
+    matrix.setTo(viewWidth / width, 0, 0, viewHeight / height, viewMarginLeft, viewMarginTop);
+    drawPixels(bgFrame, matrix);
+
+    // count background draw items for future isolation
+    bgItemCount = 0;
+    {
+      var item = _headOfDrawStack;
+      while (item != null)
+      {
+        item = item.next;
+        bgItemCount++;
+      }
+    }
+  }
+
+  function renderSkipping(count:Int):Void
+  {
+    var item = _headOfDrawStack;
+    while (item != null)
+    {
+      if (--count < 0) item.render(this);
+      item = item.next;
+    }
+  }
+
+  override function drawPixels(?frame:FlxFrame, ?pixels:BitmapData, matrix:FlxMatrix, ?transform:ColorTransform, ?blend:BlendMode, ?smoothing:Bool = false,
+      ?shader:FlxShader):Void
+  {
+    if (!shouldDraw) return;
+
+    if ( switch blend
+      {
+        case DARKEN | HARDLIGHT | LIGHTEN | OVERLAY: true;
+        case _: false;
+      })
+    {
+      // squash the screen
+      grabScreen(false);
+      // render without blend
+      super.drawPixels(frame, pixels, matrix, transform, null, smoothing, shader);
+      // get the isolated bitmap
+      final isolated = grabScreen(false, true);
+      // apply fullscreen blend
+      customBlendShader.blend = blend;
+      customBlendShader.source = isolated;
+      customBlendShader.updateViewInfo(FlxG.width, FlxG.height, this);
+      applyFilter(customBlendFilter);
+    }
+    else
+    {
+      super.drawPixels(frame, pixels, matrix, transform, blend, smoothing, shader);
+    }
+  }
+
+  override function destroy():Void
+  {
+    super.destroy();
+    disposeTextures();
+  }
+
+  override function clearDrawStack():Void
+  {
+    super.clearDrawStack();
+    // also clear grabbed bitmaps
+    for (bitmap in grabbed)
+    {
+      texturePool.push(bitmap.__texture);
+      bitmap.dispose(); // this doesn't release the texture
+    }
+    grabbed.clear();
+    // clear filters applied flag
+    filtersApplied = false;
+    bgItemCount = 0;
+  }
+
+  function pickTexture(width:Int, height:Int):TextureBase
+  {
+    // zero-sized textures will be problematic
+    width = width < 1 ? 1 : width;
+    height = height < 1 ? 1 : height;
+    if (texturePool.length > 0)
+    {
+      final res = texturePool.pop();
+      BitmapDataUtil.resizeTexture(res, width, height);
+      return res;
+    }
+    return Lib.current.stage.context3D.createTexture(width, height, BGRA, true);
+  }
+
+  function disposeTextures():Void
+  {
+    trace('disposing textures');
+    for (bitmap in grabbed)
+    {
+      bitmap.dispose();
+    }
+    grabbed.clear();
+    for (texture in texturePool)
+    {
+      texture.dispose();
+    }
+    texturePool.resize(0);
+    bgTexture.dispose();
+    bgBitmap.dispose();
+  }
+}
diff --git a/source/funkin/graphics/framebuffer/BitmapDataUtil.hx b/source/funkin/graphics/framebuffer/BitmapDataUtil.hx
new file mode 100644
index 000000000..7b49705e0
--- /dev/null
+++ b/source/funkin/graphics/framebuffer/BitmapDataUtil.hx
@@ -0,0 +1,123 @@
+package funkin.graphics.framebuffer;
+
+import flixel.FlxG;
+import openfl.Lib;
+import openfl.display.Bitmap;
+import openfl.display.BitmapData;
+import openfl.display.Sprite;
+import openfl.display3D.Context3DTextureFormat;
+import openfl.display3D.textures.TextureBase;
+import openfl.filters.BitmapFilter;
+
+/**
+ * Provides cool stuff for `BitmapData`s that have a hardware texture internally.
+ */
+@:access(openfl.display.BitmapData)
+@:access(openfl.display3D.textures.TextureBase)
+@:access(openfl.display3D.Context3D)
+class BitmapDataUtil
+{
+  static function getCache():{sprite:Sprite, bitmap:Bitmap}
+  {
+    static var cache:{sprite:Sprite, bitmap:Bitmap} = null;
+    if (cache == null)
+    {
+      final sprite = new Sprite();
+      final bitmap = new Bitmap();
+      sprite.addChild(bitmap);
+      cache =
+        {
+          sprite: sprite,
+          bitmap: bitmap
+        }
+    }
+    return cache;
+  }
+
+  /**
+   * Applies a bitmap filter to a bitmap immediately. The bitmap filter may refer
+   * the bitmap itself as a shader input.
+   * @param bitmap the bitmap data
+   * @param filter the bitmap filter
+   */
+  public static function applyFilter(bitmap:BitmapData, filter:BitmapFilter):Void
+  {
+    hardwareCheck(bitmap);
+    final cache = getCache();
+    cache.bitmap.bitmapData = bitmap;
+    cache.sprite.filters = [filter];
+    bitmap.draw(cache.sprite);
+  }
+
+  /**
+   * Creates a bitmap with a hardware texture.
+   * @param width the width
+   * @param height the height
+   * @param format the format if the internal texture
+   * @return the bitmap
+   */
+  public static function create(width:Int, height:Int, format:Context3DTextureFormat = BGRA):FixedBitmapData
+  {
+    final texture = Lib.current.stage.context3D.createTexture(width, height, format, true);
+    return FixedBitmapData.fromTexture(texture);
+  }
+
+  /**
+   * Resizes the bitmap.
+   * @param bitmap the bitmap data
+   * @param width the width
+   * @param height the height
+   */
+  public static function resize(bitmap:BitmapData, width:Int, height:Int):Void
+  {
+    hardwareCheck(bitmap);
+    if (bitmap.width == width && bitmap.height == height) return;
+    bitmap.width = width;
+    bitmap.height = height;
+    resizeTexture(bitmap.__texture, width, height);
+  }
+
+  /**
+   * Resizes the texture.
+   * @param texture the texture
+   * @param width the width
+   * @param height the height
+   */
+  public static function resizeTexture(texture:TextureBase, width:Int, height:Int):Void
+  {
+    if (texture.__width == width && texture.__height == height) return;
+    texture.__width = width;
+    texture.__height = height;
+    final context = texture.__context;
+    final gl = context.gl;
+    context.__bindGLTexture2D(texture.__textureID);
+    gl.texImage2D(gl.TEXTURE_2D, 0, texture.__internalFormat, width, height, 0, texture.__format, gl.UNSIGNED_BYTE, null);
+    context.__bindGLTexture2D(null);
+  }
+
+  /**
+   * Copies the content of `src` to `dst`. The destination bitmap `dst` will be resized
+   * so that it has the same size as `src`.
+   * @param dst the destination bitmap
+   * @param src the source bitmap
+   */
+  public static function copy(dst:BitmapData, src:BitmapData):Void
+  {
+    hardwareCheck(dst);
+    hardwareCheck(src);
+    final cache = getCache();
+    cache.bitmap.bitmapData = src;
+    cache.sprite.filters = null;
+    resize(dst, src.width, src.height);
+    dst.fillRect(dst.rect, 0);
+    dst.draw(cache.sprite);
+  }
+
+  static function hardwareCheck(bitmap:BitmapData):Void
+  {
+    if (bitmap.readable)
+    {
+      FlxG.log.error('do not use `BitmapDataUtil` for non-GPU bitmaps!');
+    }
+  }
+}
diff --git a/source/funkin/graphics/framebuffer/FixedBitmapData.hx b/source/funkin/graphics/framebuffer/FixedBitmapData.hx
new file mode 100644
index 000000000..00b39ce1c
--- /dev/null
+++ b/source/funkin/graphics/framebuffer/FixedBitmapData.hx
@@ -0,0 +1,42 @@
+package funkin.graphics.framebuffer;
+
+import openfl.display.BitmapData;
+import openfl.display.DisplayObject;
+import openfl.display.DisplayObjectContainer;
+import openfl.display.IBitmapDrawable;
+import openfl.display.OpenGLRenderer;
+import openfl.display3D.textures.TextureBase;
+
+/**
+ * `BitmapData` is kinda broken so I fixed it.
+ */
+@:access(openfl.display3D.textures.TextureBase)
+@:access(openfl.display.OpenGLRenderer)
+class FixedBitmapData extends BitmapData
+{
+  override function __drawGL(source:IBitmapDrawable, renderer:OpenGLRenderer):Void
+  {
+    if (Std.isOfType(source, DisplayObject))
+    {
+      final object:DisplayObjectContainer = cast source;
+      renderer.__stage = object.stage;
+    }
+    super.__drawGL(source, renderer);
+  }
+
+  /**
+   * Never use `BitmapData.fromTexture`, always use this.
+   * @param texture the texture
+   * @return the bitmap data
+   */
+  public static function fromTexture(texture:TextureBase):FixedBitmapData
+  {
+    if (texture == null) return null;
+    final bitmapData = new FixedBitmapData(texture.__width, texture.__height, true, 0);
+    bitmapData.readable = false;
+    bitmapData.__texture = texture;
+    bitmapData.__textureContext = texture.__textureContext;
+    bitmapData.image = null;
+    return bitmapData;
+  }
+}
diff --git a/source/funkin/graphics/framebuffer/FrameBuffer.hx b/source/funkin/graphics/framebuffer/FrameBuffer.hx
new file mode 100644
index 000000000..e99f72b77
--- /dev/null
+++ b/source/funkin/graphics/framebuffer/FrameBuffer.hx
@@ -0,0 +1,132 @@
+package funkin.graphics.framebuffer;
+
+import flixel.FlxSprite;
+import flixel.FlxCamera;
+import flixel.util.FlxColor;
+import openfl.Lib;
+import openfl.display.BitmapData;
+import openfl.display3D.textures.TextureBase;
+import openfl.geom.Matrix;
+import openfl.geom.Rectangle;
+
+/**
+ * A single frame buffer. Used by `FrameBufferManager`.
+ */
+class FrameBuffer
+{
+  /**
+   * The bitmap data of the frame buffer.
+   */
+  public var bitmap(default, null):BitmapData = null;
+
+  var texture:TextureBase;
+  final camera:FlxCamera;
+  final spriteCopies:Array<SpriteCopy> = [];
+
+  public function new()
+  {
+    camera = new FlxCamera();
+    camera.antialiasing = false;
+    camera.bgColor = FlxColor.TRANSPARENT;
+    @:privateAccess camera.flashSprite.stage = Lib.current.stage;
+  }
+
+  /**
+   * Creates a frame buffer with the given size.
+   * @param width the width
+   * @param height the height
+   * @param bgColor the background color
+   */
+  public function create(width:Int, height:Int, bgColor:FlxColor):Void
+  {
+    dispose();
+    texture = Lib.current.stage.context3D.createTexture(width, height, BGRA, true);
+    bitmap = FixedBitmapData.fromTexture(texture);
+    camera.bgColor = bgColor;
+  }
+
+  /**
+   * Makes the internal camera follows the target camera.
+   * @param target the target camera
+   */
+  public function follow(target:FlxCamera):Void
+  {
+    camera.x = target.x;
+    camera.y = target.y;
+    camera.width = target.width;
+    camera.height = target.height;
+    camera.scroll.x = target.scroll.x;
+    camera.scroll.y = target.scroll.y;
+    camera.setScale(target.scaleX, target.scaleY);
+  }
+
+  /**
+   * Locks the frame buffer and clears the buffer.
+   */
+  @:access(flixel.FlxCamera)
+  public function lock():Void
+  {
+    camera.clearDrawStack();
+    camera.canvas.graphics.clear();
+    camera.fill(camera.bgColor.to24Bit(), camera.useBgAlphaBlending, camera.bgColor.alphaFloat);
+    #if FLX_DEBUG
+    camera.debugLayer.graphics.clear();
+    #end
+  }
+
+  /**
+   * Renders all sprite copies.
+   */
+  @:access(flixel.FlxCamera)
+  public function render():Void
+  {
+    for (spriteCopy in spriteCopies)
+    {
+      spriteCopy.render(camera);
+    }
+    camera.render();
+  }
+
+  /**
+   * Unlocks the frame buffer and makes the bitmap ready to use.
+   */
+  public function unlock():Void
+  {
+    bitmap.fillRect(new Rectangle(0, 0, bitmap.width, bitmap.height), 0);
+    bitmap.draw(camera.flashSprite, new Matrix(1, 0, 0, 1, camera.flashSprite.x, camera.flashSprite.y));
+  }
+
+  /**
+   * Diposes stuff. Call `create` again if you want to reuse the instance.
+   */
+  public function dispose():Void
+  {
+    if (texture != null)
+    {
+      texture.dispose();
+      texture = null;
+      bitmap.dispose();
+      bitmap = null;
+    }
+    spriteCopies.resize(0);
+  }
+
+  /**
+   * Adds a sprite copy to the frame buffer.
+   * @param spriteCopy the sprite copy
+   */
+  public function addSpriteCopy(spriteCopy:SpriteCopy):Void
+  {
+    spriteCopies.push(spriteCopy);
+  }
+
+  /**
+   * Adds the sprite to the frame buffer. The sprite will only be seen from
+   * the frame buffer.
+   * @param sprite the sprite
+   */
+  public function moveSprite(sprite:FlxSprite):Void
+  {
+    sprite.cameras = [camera];
+  }
+}
diff --git a/source/funkin/graphics/framebuffer/FrameBufferManager.hx b/source/funkin/graphics/framebuffer/FrameBufferManager.hx
new file mode 100644
index 000000000..4d484fb8f
--- /dev/null
+++ b/source/funkin/graphics/framebuffer/FrameBufferManager.hx
@@ -0,0 +1,127 @@
+package funkin.graphics.framebuffer;
+
+import flixel.FlxCamera;
+import flixel.FlxG;
+import flixel.FlxSprite;
+import flixel.util.FlxColor;
+import openfl.display.BitmapData;
+
+/**
+ * Manages frame buffers and gives access to each frame buffer.
+ */
+class FrameBufferManager
+{
+  final camera:FlxCamera;
+  final frameBufferMap:Map<String, FrameBuffer> = [];
+
+  /**
+   * Creates a frame buffer manager that targets `camera`.
+   * @param camera the target camera.
+   */
+  public function new(camera:FlxCamera)
+  {
+    this.camera = camera;
+  }
+
+  /**
+   * Creates a new frame buffer with a name.
+   * @param name the name
+   * @param bgColor the background color
+   * @return the bitmap data of the frame buffer. the bitmap data instance
+   * will not be changed through frame buffer updates.
+   */
+  public function createFrameBuffer(name:String, bgColor:FlxColor):BitmapData
+  {
+    if (frameBufferMap.exists(name))
+    {
+      FlxG.log.warn('frame buffer "$name" already exists');
+      frameBufferMap[name].dispose();
+      frameBufferMap.remove(name);
+    }
+    final fb = new FrameBuffer();
+    fb.create(camera.width, camera.height, bgColor);
+    frameBufferMap[name] = fb;
+    return fb.bitmap;
+  }
+
+  /**
+   * Adds a copy of the sprite to the frame buffer.
+   * @param name the name of the frame buffer
+   * @param sprite the sprite
+   * @param color if this is not `null`, the sprite will be filled with the color.
+   * if this is `null`, the sprite will keep its original color.
+   */
+  public function copySpriteTo(name:String, sprite:FlxSprite, color:Null<FlxColor> = null):Void
+  {
+    if (!frameBufferMap.exists(name))
+    {
+      FlxG.log.warn('frame buffer "$name" does not exist');
+      return;
+    }
+    frameBufferMap[name].addSpriteCopy(new SpriteCopy(sprite, color));
+  }
+
+  /**
+   * Adds the sprite to the frame buffer. The sprite will only be seen from the frame buffer.
+   * @param name the name of the frame buffer
+   * @param sprite the sprite
+   */
+  public function moveSpriteTo(name:String, sprite:FlxSprite):Void
+  {
+    if (!frameBufferMap.exists(name))
+    {
+      FlxG.log.warn('frame buffer "$name" does not exist');
+      return;
+    }
+    frameBufferMap[name].moveSprite(sprite);
+  }
+
+  /**
+   * Call this before drawing anything.
+   */
+  public function lock():Void
+  {
+    for (_ => fb in frameBufferMap)
+    {
+      fb.follow(camera);
+      fb.lock();
+    }
+  }
+
+  /**
+   * Unlocks the frame buffers. This updates the bitmap data of each frame buffer.
+   */
+  public function unlock():Void
+  {
+    for (_ => fb in frameBufferMap)
+    {
+      fb.render();
+    }
+    for (_ => fb in frameBufferMap)
+    {
+      fb.unlock();
+    }
+  }
+
+  /**
+   * Returns the bitmap data of the frame buffer
+   * @param name the name of the frame buffer
+   * @return the bitmap data
+   */
+  public function getFrameBuffer(name:String):BitmapData
+  {
+    return frameBufferMap[name].bitmap;
+  }
+
+  /**
+   * Disposes all frame buffers. The instance can be reused.
+   */
+  public function dispose():Void
+  {
+    for (_ => fb in frameBufferMap)
+    {
+      fb.dispose();
+    }
+    frameBufferMap.clear();
+  }
+}
diff --git a/source/funkin/graphics/framebuffer/SpriteCopy.hx b/source/funkin/graphics/framebuffer/SpriteCopy.hx
new file mode 100644
index 000000000..ea7de69dc
--- /dev/null
+++ b/source/funkin/graphics/framebuffer/SpriteCopy.hx
@@ -0,0 +1,59 @@
+package funkin.graphics.framebuffer;
+
+import flixel.FlxCamera;
+import flixel.FlxSprite;
+import flixel.util.FlxColor;
+
+/**
+ * A copy of a `FlxSprite` with a specified color. Used to render the sprite to a frame buffer.
+ */
+class SpriteCopy
+{
+  final sprite:FlxSprite;
+  final color:Null<FlxColor>;
+
+  public function new(sprite:FlxSprite, color:Null<FlxColor>)
+  {
+    this.sprite = sprite;
+    this.color = color;
+  }
+
+  /**
+   * Renders the copy to the camera.
+   * @param camera the camera
+   */
+  @:access(flixel.FlxSprite)
+  public function render(camera:FlxCamera):Void
+  {
+    if (color == null)
+    {
+      final tmpCameras = sprite._cameras;
+      sprite._cameras = [camera];
+      sprite.draw();
+      sprite._cameras = tmpCameras;
+    }
+    else
+    {
+      final rMult = sprite.colorTransform.redMultiplier;
+      final gMult = sprite.colorTransform.greenMultiplier;
+      final bMult = sprite.colorTransform.blueMultiplier;
+      final aMult = sprite.colorTransform.alphaMultiplier;
+      final rOff = Std.int(sprite.colorTransform.redOffset);
+      final gOff = Std.int(sprite.colorTransform.greenOffset);
+      final bOff = Std.int(sprite.colorTransform.blueOffset);
+      final aOff = Std.int(sprite.colorTransform.alphaOffset);
+      final tmpCameras = sprite._cameras;
+      final tmpShader = sprite.shader;
+
+      sprite._cameras = [camera];
+      sprite.shader = null;
+
+      sprite.setColorTransform(0, 0, 0, 1, color.red, color.green, color.blue, 0);
+      sprite.draw();
+
+      sprite._cameras = tmpCameras;
+      sprite.shader = tmpShader;
+      sprite.setColorTransform(rMult, gMult, bMult, aMult, rOff, gOff, bOff, aOff);
+    }
+  }
+}
diff --git a/source/funkin/graphics/shaders/PuddleShader.hx b/source/funkin/graphics/shaders/PuddleShader.hx
new file mode 100644
index 000000000..352568737
--- /dev/null
+++ b/source/funkin/graphics/shaders/PuddleShader.hx
@@ -0,0 +1,12 @@
+package funkin.graphics.shaders;
+
+import flixel.addons.display.FlxRuntimeShader;
+import openfl.Assets;
+
+class PuddleShader extends FlxRuntimeShader
+{
+  public function new()
+  {
+    super(Assets.getText(Paths.frag('puddle')));
+  }
+}
diff --git a/source/funkin/graphics/shaders/RuntimeCustomBlendShader.hx b/source/funkin/graphics/shaders/RuntimeCustomBlendShader.hx
new file mode 100644
index 000000000..a07124d23
--- /dev/null
+++ b/source/funkin/graphics/shaders/RuntimeCustomBlendShader.hx
@@ -0,0 +1,29 @@
+package funkin.graphics.shaders;
+
+import openfl.display.BitmapData;
+import openfl.display.BlendMode;
+import openfl.utils.Assets;
+
+class RuntimeCustomBlendShader extends RuntimePostEffectShader
+{
+  public var source(default, set):BitmapData;
+
+  function set_source(value:BitmapData):BitmapData
+  {
+    this.setBitmapData("source", value);
+    return source = value;
+  }
+
+  public var blend(default, set):BlendMode;
+
+  function set_blend(value:BlendMode):BlendMode
+  {
+    this.setInt("blendMode", cast value);
+    return blend = value;
+  }
+
+  public function new()
+  {
+    super(Assets.getText("assets/shaders/customBlend.frag"));
+  }
+}
diff --git a/source/funkin/graphics/shaders/RuntimePostEffectShader.hx b/source/funkin/graphics/shaders/RuntimePostEffectShader.hx
new file mode 100644
index 000000000..9f49da075
--- /dev/null
+++ b/source/funkin/graphics/shaders/RuntimePostEffectShader.hx
@@ -0,0 +1,105 @@
+package funkin.graphics.shaders;
+
+import flixel.FlxCamera;
+import flixel.FlxG;
+import flixel.addons.display.FlxRuntimeShader;
+import lime.graphics.opengl.GLProgram;
+import lime.utils.Log;
+
+class RuntimePostEffectShader extends FlxRuntimeShader
+{
+  @:glVertexHeader('
+		// normalized screen coord
+		//   (0, 0) is the top left of the window
+		//   (1, 1) is the bottom right of the window
+		varying vec2 screenCoord;
+	', true)
+  @:glVertexBody('
+		screenCoord = vec2(
+			openfl_TextureCoord.x > 0.0 ? 1.0 : 0.0,
+			openfl_TextureCoord.y > 0.0 ? 1.0 : 0.0
+		);
+	')
+  @:glFragmentHeader('
+		// normalized screen coord
+		//   (0, 0) is the top left of the window
+		//   (1, 1) is the bottom right of the window
+		varying vec2 screenCoord;
+
+		// equals (FlxG.width, FlxG.height)
+		uniform vec2 uScreenResolution;
+
+		// equals (camera.viewLeft, camera.viewTop, camera.viewRight, camera.viewBottom)
+		uniform vec4 uCameraBounds;
+
+		// screen coord -> world coord conversion
+		// returns world coord in px
+		vec2 screenToWorld(vec2 screenCoord) {
+			float left = uCameraBounds.x;
+			float top = uCameraBounds.y;
+			float right = uCameraBounds.z;
+			float bottom = uCameraBounds.w;
+			vec2 scale = vec2(right - left, bottom - top);
+			vec2 offset = vec2(left, top);
+			return screenCoord * scale + offset;
+		}
+
+		// world coord -> screen coord conversion
+		// returns normalized screen coord
+		vec2 worldToScreen(vec2 worldCoord) {
+			float left = uCameraBounds.x;
+			float top = uCameraBounds.y;
+			float right = uCameraBounds.z;
+			float bottom = uCameraBounds.w;
+			vec2 scale = vec2(right - left, bottom - top);
+			vec2 offset = vec2(left, top);
+			return (worldCoord - offset) / scale;
+		}
+
+		// internally used to get the maximum `openfl_TextureCoordv`
+		vec2 bitmapCoordScale() {
+			return openfl_TextureCoordv / screenCoord;
+		}
+
+		// internally used to compute bitmap coord
+		vec2 screenToBitmap(vec2 screenCoord) {
+			return screenCoord * bitmapCoordScale();
+		}
+
+		// samples the frame buffer using a screen coord
+		vec4 sampleBitmapScreen(vec2 screenCoord) {
+			return texture2D(bitmap, screenToBitmap(screenCoord));
+		}
+
+		// samples the frame buffer using a world coord
+		vec4 sampleBitmapWorld(vec2 worldCoord) {
+			return sampleBitmapScreen(worldToScreen(worldCoord));
+		}
+	', true)
+  public function new(fragmentSource:String = null, glVersion:String = null)
+  {
+    super(fragmentSource, null, glVersion);
+    uScreenResolution.value = [FlxG.width, FlxG.height];
+  }
+
+  // basically `updateViewInfo(FlxG.width, FlxG.height, FlxG.camera)` is good
+  public function updateViewInfo(screenWidth:Float, screenHeight:Float, camera:FlxCamera):Void
+  {
+    uScreenResolution.value = [screenWidth, screenHeight];
+    uCameraBounds.value = [camera.viewLeft, camera.viewTop, camera.viewRight, camera.viewBottom];
+  }
+
+  override function __createGLProgram(vertexSource:String, fragmentSource:String):GLProgram
+  {
+    try
+    {
+      final res = super.__createGLProgram(vertexSource, fragmentSource);
+      return res;
+    }
+    catch (error)
+    {
+      Log.warn(error); // prevent the app from dying immediately
+      return null;
+    }
+  }
+}
diff --git a/source/funkin/graphics/shaders/RuntimeRainShader.hx b/source/funkin/graphics/shaders/RuntimeRainShader.hx
new file mode 100644
index 000000000..05bc68f72
--- /dev/null
+++ b/source/funkin/graphics/shaders/RuntimeRainShader.hx
@@ -0,0 +1,144 @@
+package funkin.graphics.shaders;
+
+import flixel.system.FlxAssets.FlxShader;
+import openfl.display.BitmapData;
+import openfl.display.ShaderParameter;
+import openfl.display.ShaderParameterType;
+import openfl.utils.Assets;
+
+typedef Light =
+{
+  var position:Array<Float>;
+  var color:Array<Float>;
+  var radius:Float;
+}
+
+class RuntimeRainShader extends RuntimePostEffectShader
+{
+  static final MAX_LIGHTS:Int = 8;
+
+  public var lights:Array<
+    {
+      position:ShaderParameter<Float>,
+      color:ShaderParameter<Float>,
+      radius:ShaderParameter<Float>,
+    }>;
+
+  public var time(default, set):Float = 1;
+
+  function set_time(value:Float):Float
+  {
+    this.setFloat('uTime', value);
+    return time = value;
+  }
+
+  // The scale of the rain depends on the world coordinate system, so higher resolution makes
+  // the raindrops smaller. This parameter can be used to adjust the total scale of the scene.
+  // The size of the raindrops is proportional to the value of this parameter.
+  public var scale(default, set):Float = 1;
+
+  function set_scale(value:Float):Float
+  {
+    this.setFloat('uScale', value);
+    return scale = value;
+  }
+
+  // The intensity of the rain. Zero means no rain and one means the maximum amount of rain.
+  public var intensity(default, set):Float = 0.5;
+
+  function set_intensity(value:Float):Float
+  {
+    this.setFloat('uIntensity', value);
+    return intensity = value;
+  }
+
+  // the y coord of the puddle, used to mirror things
+  public var puddleY(default, set):Float = 0;
+
+  function set_puddleY(value:Float):Float
+  {
+    this.setFloat('uPuddleY', value);
+    return puddleY = value;
+  }
+
+  // the y scale of the puddle, the less this value the more the puddle effects squished
+  public var puddleScaleY(default, set):Float = 0;
+
+  function set_puddleScaleY(value:Float):Float
+  {
+    this.setFloat('uPuddleScaleY', value);
+    return puddleScaleY = value;
+  }
+
+  public var blurredScreen(default, set):BitmapData;
+
+  function set_blurredScreen(value:BitmapData):BitmapData
+  {
+    this.setBitmapData('uBlurredScreen', value);
+    return blurredScreen = value;
+  }
+
+  public var mask(default, set):BitmapData;
+
+  function set_mask(value:BitmapData):BitmapData
+  {
+    this.setBitmapData('uMask', value);
+    return mask = value;
+  }
+
+  public var lightMap(default, set):BitmapData;
+
+  function set_lightMap(value:BitmapData):BitmapData
+  {
+    this.setBitmapData('uLightMap', value);
+    return lightMap = value;
+  }
+
+  public var numLights(default, set):Int = 0;
+
+  function set_numLights(value:Int):Int
+  {
+    this.setInt('numLights', value);
+    return numLights = value;
+  }
+
+  public function new()
+  {
+    super(Assets.getText(Paths.frag('rain')));
+  }
+
+  public function update(elapsed:Float):Void
+  {
+    time += elapsed;
+  }
+
+  override function __processGLData(source:String, storageType:String):Void
+  {
+    super.__processGLData(source, storageType);
+    if (storageType == 'uniform')
+    {
+      lights = [
+        for (i in 0...MAX_LIGHTS)
+          {
+            position: addFloatUniform('lights[$i].position', 2),
+            color: addFloatUniform('lights[$i].color', 3),
+            radius: addFloatUniform('lights[$i].radius', 1),
+          }
+      ];
+    }
+  }
+
+  @:access(openfl.display.ShaderParameter)
+  function addFloatUniform(name:String, length:Int):ShaderParameter<Float>
+  {
+    final res = new ShaderParameter<Float>();
+    res.name = name;
+    res.type = [null, FLOAT, FLOAT2, FLOAT3, FLOAT4][length];
+    res.__arrayLength = 1;
+    res.__isFloat = true;
+    res.__isUniform = true;
+    res.__length = length;
+    __paramFloat.push(res);
+    return res;
+  }
+}
diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index 5ca6b03db..02055d4ed 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -6,6 +6,7 @@ import funkin.util.Constants;
 import funkin.Paths;
 import funkin.Preferences;
 import flixel.FlxG; // This one in particular causes a compile error if you're using macros.
+import flixel.system.debug.watch.Tracker;
 
 // These are great.
 using Lambda;
diff --git a/source/funkin/modding/base/ScriptedFlxAtlasSprite.hx b/source/funkin/modding/base/ScriptedFlxAtlasSprite.hx
new file mode 100644
index 000000000..e9ffd450d
--- /dev/null
+++ b/source/funkin/modding/base/ScriptedFlxAtlasSprite.hx
@@ -0,0 +1,8 @@
+package funkin.modding.base;
+
+/**
+ * A script that can be tied to an FlxAtlasSprite
+ * Create a scripted class that extends FlxAtlasSprite to use this.
+ */
+@:hscriptClass
+class ScriptedFlxAtlasSprite extends funkin.graphics.adobeanimate.FlxAtlasSprite implements HScriptedClass {}
diff --git a/source/funkin/modding/base/ScriptedFunkinSprite.hx b/source/funkin/modding/base/ScriptedFunkinSprite.hx
new file mode 100644
index 000000000..dd8d15007
--- /dev/null
+++ b/source/funkin/modding/base/ScriptedFunkinSprite.hx
@@ -0,0 +1,8 @@
+package funkin.modding.base;
+
+/**
+ * A script that can be tied to an FlxSprite.
+ * Create a scripted class that extends FlxSprite to use this.
+ */
+@:hscriptClass
+class ScriptedFunkinSprite extends funkin.graphics.FunkinSprite implements HScriptedClass {}
diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index 10e59f078..023b8d5be 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -38,7 +38,8 @@ class PauseSubState extends MusicBeatSubState
 
   var practiceText:FlxText;
 
-  var exitingToMenu:Bool = false;
+  public var exitingToMenu:Bool = false;
+
   var bg:FlxSprite;
   var metaDataGrp:FlxTypedGroup<FlxSprite>;
 
@@ -234,7 +235,7 @@ class PauseSubState extends MusicBeatSubState
             if (PlayStatePlaylist.isStoryMode)
             {
               PlayStatePlaylist.reset();
-              openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.story.StoryMenuState()));
+              openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.story.StoryMenuState(sticker)));
             }
             else
             {
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 74347765e..be4fab254 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1,68 +1,79 @@
 package funkin.play;
 
-import funkin.ui.SwagCamera;
-import flixel.addons.transition.FlxTransitionableSubState;
-import funkin.ui.debug.charting.ChartEditorState;
-import haxe.Int64;
-import funkin.play.notes.notestyle.NoteStyle;
-import funkin.data.notestyle.NoteStyleData;
-import funkin.data.notestyle.NoteStyleRegistry;
 import flixel.addons.display.FlxPieDial;
-import flixel.addons.transition.Transition;
+import flixel.addons.display.FlxPieDial;
 import flixel.addons.transition.FlxTransitionableState;
+import flixel.addons.transition.FlxTransitionableState;
+import flixel.addons.transition.FlxTransitionableSubState;
+import flixel.addons.transition.FlxTransitionableSubState;
+import flixel.addons.transition.Transition;
+import flixel.addons.transition.Transition;
 import flixel.FlxCamera;
 import flixel.FlxObject;
 import flixel.FlxSprite;
 import flixel.FlxState;
 import flixel.FlxSubState;
-import flixel.input.keyboard.FlxKey;
 import flixel.math.FlxMath;
-import funkin.play.components.ComboMilestone;
 import flixel.math.FlxPoint;
-import funkin.play.components.HealthIcon;
-import funkin.ui.MusicBeatSubState;
 import flixel.math.FlxRect;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.ui.FlxBar;
 import flixel.util.FlxColor;
-import funkin.api.newgrounds.NGio;
 import flixel.util.FlxTimer;
+import funkin.api.newgrounds.NGio;
 import funkin.audio.VoicesGroup;
-import funkin.save.Save;
+import funkin.audio.VoicesGroup;
+import funkin.data.dialogue.ConversationRegistry;
+import funkin.data.event.SongEventRegistry;
+import funkin.data.notestyle.NoteStyleData;
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongRegistry;
+import funkin.data.stage.StageRegistry;
 import funkin.Highscore.Tallies;
 import funkin.input.PreciseInputManager;
 import funkin.modding.events.ScriptEvent;
-import funkin.ui.mainmenu.MainMenuState;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.character.BaseCharacter;
 import funkin.play.character.CharacterData.CharacterDataParser;
+import funkin.play.components.ComboMilestone;
+import funkin.play.components.HealthIcon;
+import funkin.play.components.PopUpStuff;
+import funkin.play.cutscene.dialogue.Conversation;
 import funkin.play.cutscene.dialogue.Conversation;
-import funkin.data.dialogue.ConversationRegistry;
 import funkin.play.cutscene.VanillaCutscenes;
 import funkin.play.cutscene.VideoCutscene;
-import funkin.data.event.SongEventRegistry;
-import funkin.play.notes.NoteSprite;
 import funkin.play.notes.NoteDirection;
+import funkin.play.notes.NoteSplash;
+import funkin.play.notes.NoteSprite;
+import funkin.play.notes.NoteSprite;
+import funkin.play.notes.notestyle.NoteStyle;
+import funkin.play.notes.notestyle.NoteStyle;
 import funkin.play.notes.Strumline;
 import funkin.play.notes.SustainTrail;
 import funkin.play.scoring.Scoring;
 import funkin.play.song.Song;
-import funkin.data.song.SongRegistry;
-import funkin.data.stage.StageRegistry;
-import funkin.data.song.SongData.SongEventData;
-import funkin.data.song.SongData.SongNoteData;
-import funkin.data.song.SongData.SongCharacterData;
 import funkin.play.stage.Stage;
-import funkin.ui.transition.LoadingState;
-import funkin.play.components.PopUpStuff;
-import funkin.ui.options.PreferencesMenu;
+import funkin.save.Save;
+import funkin.ui.debug.charting.ChartEditorState;
 import funkin.ui.debug.stage.StageOffsetSubState;
+import funkin.ui.mainmenu.MainMenuState;
+import funkin.ui.MusicBeatSubState;
+import funkin.ui.options.PreferencesMenu;
 import funkin.ui.story.StoryMenuState;
+import funkin.graphics.FunkinCamera;
+import funkin.ui.transition.LoadingState;
 import funkin.util.SerializerUtil;
-import funkin.util.SortUtil;
+import haxe.Int64;
 import lime.ui.Haptic;
+import openfl.display.BitmapData;
+import openfl.geom.Rectangle;
+import openfl.Lib;
 #if discord_rpc
 import Discord.DiscordClient;
 #end
@@ -445,6 +456,17 @@ class PlayState extends MusicBeatSubState
     return this.subState != null;
   }
 
+  var isExitingViaPauseMenu(get, never):Bool;
+
+  function get_isExitingViaPauseMenu():Bool
+  {
+    if (this.subState == null) return false;
+    if (!Std.isOfType(this.subState, PauseSubState)) return false;
+
+    var pauseSubState:PauseSubState = cast this.subState;
+    return pauseSubState.exitingToMenu;
+  }
+
   /**
    * Data for the current difficulty for the current song.
    * Includes chart data, scroll speed, and other information.
@@ -885,7 +907,7 @@ class PlayState extends MusicBeatSubState
     // TODO: Add a song event for Handle GF dance speed.
 
     // Handle player death.
-    if (!isInCutscene && !disableKeys && !_exiting)
+    if (!isInCutscene && !disableKeys)
     {
       // RESET = Quick Game Over Screen
       if (controls.RESET)
@@ -1282,7 +1304,7 @@ class PlayState extends MusicBeatSubState
    */
   function initCameras():Void
   {
-    camGame = new SwagCamera();
+    camGame = new FunkinCamera();
     camGame.bgColor = BACKGROUND_COLOR; // Show a pink background behind the stage.
     camHUD = new FlxCamera();
     camHUD.bgColor.alpha = 0; // Show the game scene behind the camera.
@@ -1739,7 +1761,7 @@ class PlayState extends MusicBeatSubState
    */
   function resyncVocals():Void
   {
-    if (_exiting || vocals == null) return;
+    if (vocals == null) return;
 
     // Skip this if the music is paused (GameOver, Pause menu, start-of-song offset, etc.)
     if (!FlxG.sound.music.playing) return;
@@ -1965,7 +1987,7 @@ class PlayState extends MusicBeatSubState
 
         // Mute vocals and play miss animation, but don't penalize.
         vocals.playerVolume = 0;
-        currentStage.getBoyfriend().playSingAnimation(holdNote.noteData.getDirection(), true);
+        if (currentStage != null && currentStage.getBoyfriend() != null) currentStage.getBoyfriend().playSingAnimation(holdNote.noteData.getDirection(), true);
       }
     }
   }
@@ -2519,8 +2541,8 @@ class PlayState extends MusicBeatSubState
       {
         FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'));
 
-        transIn = FlxTransitionableState.defaultTransIn;
-        transOut = FlxTransitionableState.defaultTransOut;
+        // transIn = FlxTransitionableState.defaultTransIn;
+        // transOut = FlxTransitionableState.defaultTransOut;
 
         // TODO: Rework week unlock logic.
         // StoryMenuState.weekUnlocked[Std.int(Math.min(storyWeek + 1, StoryMenuState.weekUnlocked.length - 1))] = true;
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index caa576bcf..9ffeefcfd 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -352,11 +352,11 @@ class ResultState extends MusicBeatSubState
     {
       if (params.storyMode)
       {
-        FlxG.switchState(() -> new StoryMenuState());
+        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new StoryMenuState(sticker)));
       }
       else
       {
-        FlxG.switchState(() -> new FreeplayState());
+        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new FreeplayState(null, sticker)));
       }
     }
 
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index ac6c3705e..af5765b25 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -1,10 +1,17 @@
 package funkin.play.stage;
 
+import openfl.display.BlendMode;
+import funkin.graphics.framebuffer.FrameBufferManager;
+import flixel.util.FlxColor;
+import funkin.graphics.framebuffer.SpriteCopy;
+import funkin.graphics.FunkinCamera;
+import flixel.FlxCamera;
 import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup;
 import flixel.math.FlxPoint;
 import flixel.system.FlxAssets.FlxShader;
 import flixel.util.FlxSort;
+import openfl.display.BitmapData;
 import flixel.util.FlxColor;
 import funkin.modding.IScriptedClass;
 import funkin.modding.events.ScriptEvent;
@@ -46,6 +53,13 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
     return _data?.cameraZoom ?? 1.0;
   }
 
+  var frameBufferMan:FrameBufferManager;
+
+  /**
+   * The texture that has the mask information. Used for shader effects.
+   */
+  public var maskTexture:BitmapData;
+
   var namedProps:Map<String, StageProp> = new Map<String, StageProp>();
   var characters:Map<String, BaseCharacter> = new Map<String, BaseCharacter>();
   var boppers:Array<Bopper> = new Array<Bopper>();
@@ -75,6 +89,10 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
    */
   public function onCreate(event:ScriptEvent):Void
   {
+    if (frameBufferMan != null) frameBufferMan.dispose();
+    frameBufferMan = new FrameBufferManager(FlxG.camera);
+    setupFrameBuffers();
+
     buildStage();
     this.refresh();
 
@@ -701,6 +719,11 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
     {
       debugIconGroup = null;
     }
+
+    if (frameBufferMan != null)
+    {
+      frameBufferMan.dispose();
+    }
   }
 
   /**
@@ -724,13 +747,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
     }
   }
 
-  public function onUpdate(event:UpdateScriptEvent)
-  {
-    if (FlxG.keys.justPressed.F3)
-    {
-      debugIconGroup.visible = !debugIconGroup.visible;
-    }
-  }
+  public function onUpdate(event:UpdateScriptEvent) {}
 
   public override function kill()
   {
@@ -753,6 +770,53 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
     return Sprite;
   }
 
+  override function draw():Void
+  {
+    if (frameBufferMan != null)
+    {
+      frameBufferMan.lock();
+    }
+    super.draw();
+    if (frameBufferMan != null)
+    {
+      frameBufferMan.unlock();
+    }
+    frameBuffersUpdated();
+  }
+
+  /**
+   * Called when the frame buffer manager is ready.
+   * Create frame buffers inside this method.
+   */
+  function setupFrameBuffers():Void {}
+
+  /**
+   * Called when all the frame buffers are updated. If you need any
+   * frame buffers before `grabScreen()`, make sure you
+   * grab the screen inside this method since it immediately uses the
+   * frame buffers.
+   */
+  function frameBuffersUpdated():Void {}
+
+  /**
+   * Grabs the current screen and returns it as a bitmap data. You can sefely modify it.
+   * @param applyFilters if this is `true`, the filters set to the camera will be applied to the resulting bitmap
+   * @return the grabbed screen
+   */
+  function grabScreen(applyFilters:Bool):BitmapData
+  {
+    if (Std.isOfType(FlxG.camera, FunkinCamera))
+    {
+      final cam:FunkinCamera = cast FlxG.camera;
+      return cam.grabScreen(applyFilters);
+    }
+    else
+    {
+      FlxG.log.error('cannot grab the screen: the main camera is not grabbable');
+      return null;
+    }
+  }
+
   static function _fetchData(id:String):Null<StageData>
   {
     return StageRegistry.instance.parseEntryDataWithMigration(id, StageRegistry.instance.fetchEntryVersion(id));
diff --git a/source/funkin/ui/MusicBeatSubState.hx b/source/funkin/ui/MusicBeatSubState.hx
index 17c6e7fad..9a6f1a323 100644
--- a/source/funkin/ui/MusicBeatSubState.hx
+++ b/source/funkin/ui/MusicBeatSubState.hx
@@ -16,7 +16,7 @@ import funkin.input.Controls;
 /**
  * MusicBeatSubState reincorporates the functionality of MusicBeatState into an FlxSubState.
  */
-class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandler
+class MusicBeatSubState extends FlxSubState implements IEventHandler
 {
   public var leftWatermarkText:FlxText = null;
   public var rightWatermarkText:FlxText = null;
diff --git a/source/funkin/ui/SwagCamera.hx b/source/funkin/ui/SwagCamera.hx
deleted file mode 100644
index 70791d38f..000000000
--- a/source/funkin/ui/SwagCamera.hx
+++ /dev/null
@@ -1,103 +0,0 @@
-package funkin.ui;
-
-import flixel.FlxCamera;
-import flixel.FlxSprite;
-import flixel.math.FlxPoint;
-import funkin.util.MathUtil;
-
-class SwagCamera extends FlxCamera
-{
-  /**
-   * properly follow framerate
-   * most of this just copied from FlxCamera,
-   * only lines 96 and 97 are changed
-   */
-  override public function updateFollow():Void
-  {
-    // Either follow the object closely,
-    // or double check our deadzone and update accordingly.
-    if (deadzone == null)
-    {
-      target.getMidpoint(_point);
-      _point.addPoint(targetOffset);
-      focusOn(_point);
-    }
-    else
-    {
-      var edge:Float;
-      var targetX:Float = target.x + targetOffset.x;
-      var targetY:Float = target.y + targetOffset.y;
-
-      if (style == SCREEN_BY_SCREEN)
-      {
-        if (targetX >= (scroll.x + width))
-        {
-          _scrollTarget.x += width;
-        }
-        else if (targetX < scroll.x)
-        {
-          _scrollTarget.x -= width;
-        }
-
-        if (targetY >= (scroll.y + height))
-        {
-          _scrollTarget.y += height;
-        }
-        else if (targetY < scroll.y)
-        {
-          _scrollTarget.y -= height;
-        }
-      }
-      else
-      {
-        edge = targetX - deadzone.x;
-        if (_scrollTarget.x > edge)
-        {
-          _scrollTarget.x = edge;
-        }
-        edge = targetX + target.width - deadzone.x - deadzone.width;
-        if (_scrollTarget.x < edge)
-        {
-          _scrollTarget.x = edge;
-        }
-
-        edge = targetY - deadzone.y;
-        if (_scrollTarget.y > edge)
-        {
-          _scrollTarget.y = edge;
-        }
-        edge = targetY + target.height - deadzone.y - deadzone.height;
-        if (_scrollTarget.y < edge)
-        {
-          _scrollTarget.y = edge;
-        }
-      }
-
-      if ((target is FlxSprite))
-      {
-        if (_lastTargetPosition == null)
-        {
-          _lastTargetPosition = FlxPoint.get(target.x, target.y); // Creates this point.
-        }
-        _scrollTarget.x += (target.x - _lastTargetPosition.x) * followLead.x;
-        _scrollTarget.y += (target.y - _lastTargetPosition.y) * followLead.y;
-
-        _lastTargetPosition.x = target.x;
-        _lastTargetPosition.y = target.y;
-      }
-
-      if (followLerp >= 60 / FlxG.updateFramerate)
-      {
-        scroll.copyFrom(_scrollTarget); // no easing
-      }
-      else
-      {
-        // THIS THE PART THAT ACTUALLY MATTERS LOL
-        scroll.x = MathUtil.coolLerp(scroll.x, _scrollTarget.x, followLerp);
-        scroll.y = MathUtil.coolLerp(scroll.y, _scrollTarget.y, followLerp);
-        // scroll.x += (_scrollTarget.x - scroll.x) * MathUtil.cameraLerp(followLerp);
-        // scroll.y += (_scrollTarget.y - scroll.y) * MathUtil.cameraLerp(followLerp);
-      }
-    }
-  }
-}
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 78b445318..6c64f952b 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -8,6 +8,7 @@ import flixel.FlxSprite;
 import flixel.FlxSubState;
 import flixel.graphics.FlxGraphic;
 import flixel.group.FlxGroup.FlxTypedGroup;
+import funkin.graphics.FunkinCamera;
 import flixel.group.FlxSpriteGroup;
 import flixel.input.keyboard.FlxKey;
 import flixel.input.mouse.FlxMouseEvent;
@@ -2094,7 +2095,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     loadPreferences();
 
-    uiCamera = new FlxCamera();
+    uiCamera = new FunkinCamera();
     FlxG.cameras.reset(uiCamera);
 
     buildDefaultSongData();
@@ -5380,7 +5381,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     // Kill and replace the UI camera so it doesn't get destroyed during the state transition.
     uiCamera.kill();
     FlxG.cameras.remove(uiCamera, false);
-    FlxG.cameras.reset(new FlxCamera());
+    FlxG.cameras.reset(new FunkinCamera());
 
     this.persistentUpdate = false;
     this.persistentDraw = false;
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 9cbab2cb5..e7c615313 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -14,6 +14,7 @@ import flixel.group.FlxSpriteGroup;
 import flixel.input.touch.FlxTouch;
 import flixel.math.FlxAngle;
 import flixel.math.FlxMath;
+import funkin.graphics.FunkinCamera;
 import flixel.math.FlxPoint;
 import flixel.system.debug.watch.Tracker.TrackerProfile;
 import flixel.text.FlxText;
@@ -573,7 +574,7 @@ class FreeplayState extends MusicBeatSubState
 
     var swag:Alphabet = new Alphabet(1, 0, "swag");
 
-    var funnyCam = new FlxCamera(0, 0, FlxG.width, FlxG.height);
+    var funnyCam = new FunkinCamera(0, 0, FlxG.width, FlxG.height);
     funnyCam.bgColor = FlxColor.TRANSPARENT;
     FlxG.cameras.add(funnyCam);
 
diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index 3da041ada..8842c37de 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -13,6 +13,7 @@ import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.input.touch.FlxTouch;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
+import funkin.graphics.FunkinCamera;
 import flixel.tweens.FlxTween;
 import funkin.ui.MusicBeatState;
 import flixel.util.FlxTimer;
@@ -152,7 +153,7 @@ class MainMenuState extends MusicBeatState
 
   function resetCamStuff()
   {
-    FlxG.cameras.reset(new SwagCamera());
+    FlxG.cameras.reset(new FunkinCamera());
     FlxG.camera.follow(camFollow, null, 0.06);
   }
 
diff --git a/source/funkin/ui/options/ControlsMenu.hx b/source/funkin/ui/options/ControlsMenu.hx
index ea04e1208..bda071846 100644
--- a/source/funkin/ui/options/ControlsMenu.hx
+++ b/source/funkin/ui/options/ControlsMenu.hx
@@ -4,6 +4,7 @@ import funkin.util.InputUtil;
 import flixel.FlxCamera;
 import flixel.FlxObject;
 import flixel.FlxSprite;
+import funkin.graphics.FunkinCamera;
 import flixel.group.FlxGroup;
 import flixel.input.actions.FlxActionInput;
 import flixel.input.gamepad.FlxGamepadInputID;
@@ -47,7 +48,7 @@ class ControlsMenu extends funkin.ui.options.OptionsState.Page
   {
     super();
 
-    menuCamera = new FlxCamera();
+    menuCamera = new FunkinCamera();
     FlxG.cameras.add(menuCamera, false);
     menuCamera.bgColor = 0x0;
     camera = menuCamera;
diff --git a/source/funkin/ui/options/PreferencesMenu.hx b/source/funkin/ui/options/PreferencesMenu.hx
index b4b3c7db8..c23c3f165 100644
--- a/source/funkin/ui/options/PreferencesMenu.hx
+++ b/source/funkin/ui/options/PreferencesMenu.hx
@@ -6,6 +6,7 @@ import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import funkin.ui.AtlasText.AtlasFont;
 import funkin.ui.options.OptionsState.Page;
+import funkin.graphics.FunkinCamera;
 import funkin.ui.TextMenuList.TextMenuItem;
 
 class PreferencesMenu extends Page
@@ -20,7 +21,7 @@ class PreferencesMenu extends Page
   {
     super();
 
-    menuCamera = new SwagCamera();
+    menuCamera = new FunkinCamera();
     FlxG.cameras.add(menuCamera, false);
     menuCamera.bgColor = 0x0;
     camera = menuCamera;
diff --git a/source/funkin/ui/transition/StickerSubState.hx b/source/funkin/ui/transition/StickerSubState.hx
index fa36cfd50..e94eed7d5 100644
--- a/source/funkin/ui/transition/StickerSubState.hx
+++ b/source/funkin/ui/transition/StickerSubState.hx
@@ -116,10 +116,19 @@ class StickerSubState extends MusicBeatSubState
   {
     grpStickers.cameras = FlxG.cameras.list;
 
-    if (dipshit != null)
+    /*
+      if (dipshit != null)
+      {
+        FlxG.removeChild(dipshit);
+        dipshit = null;
+      }
+     */
+
+    if (grpStickers.members == null || grpStickers.members.length == 0)
     {
-      FlxG.removeChild(dipshit);
-      dipshit = null;
+      switchingState = false;
+      close();
+      return;
     }
 
     for (ind => sticker in grpStickers.members)
@@ -232,18 +241,23 @@ class StickerSubState extends MusicBeatSubState
           if (ind == grpStickers.members.length - 1)
           {
             switchingState = true;
+
             FlxTransitionableState.skipNextTransIn = true;
             FlxTransitionableState.skipNextTransOut = true;
 
-            dipshit = new Sprite();
-            var scrn:BitmapData = new BitmapData(FlxG.width, FlxG.height, true, 0x00000000);
-            var mat:Matrix = new Matrix();
-            scrn.draw(grpStickers.cameras[0].canvas, mat);
+            // I think this grabs the screen and puts it under the stickers?
+            // Leaving this commented out rather than stripping it out because it's cool...
+            /*
+              dipshit = new Sprite();
+              var scrn:BitmapData = new BitmapData(FlxG.width, FlxG.height, true, 0x00000000);
+              var mat:Matrix = new Matrix();
+              scrn.draw(grpStickers.cameras[0].canvas, mat);
 
-            var bitmap:Bitmap = new Bitmap(scrn);
+              var bitmap:Bitmap = new Bitmap(scrn);
 
-            dipshit.addChild(bitmap);
-            FlxG.addChildBelowMouse(dipshit);
+              dipshit.addChild(bitmap);
+              // FlxG.addChildBelowMouse(dipshit);
+             */
 
             FlxG.switchState(() -> targetState(this));
           }
diff --git a/source/funkin/util/plugins/WatchPlugin.hx b/source/funkin/util/plugins/WatchPlugin.hx
index 17b2dd129..defd9797d 100644
--- a/source/funkin/util/plugins/WatchPlugin.hx
+++ b/source/funkin/util/plugins/WatchPlugin.hx
@@ -22,6 +22,17 @@ class WatchPlugin extends FlxBasic
   {
     super.update(elapsed);
 
+    var stateClassName = Type.getClassName(Type.getClass(FlxG.state));
+    FlxG.watch.addQuick("currentState", stateClassName);
+    var subStateClassNames = [];
+    var subState = FlxG.state.subState;
+    while (subState != null)
+    {
+      subStateClassNames.push(Type.getClassName(Type.getClass(subState)));
+      subState = subState.subState;
+    }
+    FlxG.watch.addQuick("currentSubStates", subStateClassNames.join(", "));
+
     FlxG.watch.addQuick("songPosition", Conductor.instance.songPosition);
     FlxG.watch.addQuick("songPositionNoOffset", Conductor.instance.songPosition + Conductor.instance.instrumentalOffset);
     FlxG.watch.addQuick("musicTime", FlxG.sound?.music?.time ?? 0.0);