diff --git a/src/equicordplugins/snowfall/index.tsx b/src/equicordplugins/snowfall/index.tsx
new file mode 100644
index 000000000..f5390502c
--- /dev/null
+++ b/src/equicordplugins/snowfall/index.tsx
@@ -0,0 +1,491 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2025 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { definePluginSettings } from "@api/Settings";
+import { Heading } from "@components/Heading";
+import { Paragraph } from "@components/Paragraph";
+import { EquicordDevs } from "@utils/constants";
+import definePlugin, { OptionType } from "@utils/types";
+import { createRoot, React } from "@webpack/common";
+import type { Root } from "react-dom/client";
+
+const SnowfallCSS = `
+#snowfield {
+ pointer-events: none;
+ user-select: none;
+ z-index: 100000;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+}
+.snowflake {
+ position: absolute;
+ color: #fff;
+ line-height: 1;
+ -webkit-font-smoothing: antialiased;
+}
+.snowflake-solid {
+ border-radius: 50%;
+ background: #ffffff;
+}
+.snowflake-image {
+ filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.5));
+}
+`;
+
+// SVG snowflake images as data URIs
+const SNOWFLAKE_SVGS = [
+ // 6-pointed snowflake
+ "data:image/svg+xml," + encodeURIComponent(`
+
+ `),
+ // 8-pointed star snowflake
+ "data:image/svg+xml," + encodeURIComponent(`
+
+ `),
+ // Discord snowflake
+ "data:image/svg+xml," + encodeURIComponent(`
+
+ `),
+];
+
+const settings = definePluginSettings({
+ typeOfSnow: {
+ description: "Change the type of snow displayed (Affects performance).",
+ type: OptionType.SELECT,
+ options: [
+ { label: "Solid (Highest Performance)", value: "solid" },
+ { label: "Text (Medium Performance)", value: "text", default: true },
+ { label: "Image (Lowest Performance)", value: "image" }
+ ],
+ },
+ maxSize: {
+ description: "Maximum snowflake size",
+ type: OptionType.SLIDER,
+ default: 30,
+ markers: [10, 20, 30, 40, 50]
+ },
+ speed: {
+ description: "Snowfall speed (higher = faster fall)",
+ type: OptionType.SLIDER,
+ default: 50,
+ markers: [50, 100, 200, 300, 400, 500]
+ },
+ flakesPerSecond: {
+ description: "Snowflakes per second (higher = denser snowfall)",
+ type: OptionType.SLIDER,
+ default: 5,
+ markers: [1, 5, 10, 20, 40, 60],
+ min: 1,
+ max: 60,
+ }
+});
+
+class CopleSnow {
+ private static winWidth = window.innerWidth;
+ private static winHeight = window.innerHeight;
+
+ static readonly defaultOptions = {
+ minSize: 10,
+ maxSize: 30,
+ type: "text" as "text" | "solid" | "image",
+ content: "❄" as string | string[],
+ fadeOut: true,
+ autoplay: true,
+ interval: 200
+ };
+
+ private static cssPrefix(propertyName: string): string | null {
+ const capitalize = propertyName.charAt(0).toUpperCase() + propertyName.slice(1);
+ const tempDiv = document.createElement("div");
+ const { style } = tempDiv;
+ const prefixes = ["Webkit", "Moz", "ms", "O"];
+
+ if (propertyName in style) return propertyName;
+ for (const prefix of prefixes) {
+ const name = prefix + capitalize;
+ if (name in style) return name;
+ }
+ return null;
+ }
+
+ private static readonly cssPrefixedNames = {
+ transform: this.cssPrefix("transform"),
+ transition: this.cssPrefix("transition")
+ };
+
+ private static readonly transitionEndEvent =
+ { WebkitTransition: "webkitTransitionEnd", OTransition: "oTransitionEnd", Moztransition: "transitionend", transition: "transitionend" }[
+ this.cssPrefixedNames.transition ?? "transition"
+ ] ?? "transitionend";
+
+ private static random(min: number, max: number, deviation?: number): number {
+ if (!deviation) {
+ deviation *= max;
+ max += deviation;
+ min = max - deviation;
+ } else {
+ min = min || 0;
+ }
+ return Math.floor(Math.random() * (max - min + 1) + min);
+ }
+
+ private static setStyle(element: HTMLElement, rules: Record) {
+ for (const [name, value] of Object.entries(rules)) {
+ const cssName = CopleSnow.cssPrefixedNames[name as keyof typeof CopleSnow.cssPrefixedNames] || name;
+ (element.style as any)[cssName] = value;
+ }
+ }
+
+ private options = { ...CopleSnow.defaultOptions };
+ private queue: HTMLElement[] = [];
+ private $snowfield: HTMLDivElement;
+ private timer: number | null = null;
+ public playing = false;
+
+ constructor(newOptions: Partial = {}) {
+ Object.assign(this.options, newOptions);
+
+ this.$snowfield = document.createElement("div");
+ this.$snowfield.id = "snowfield";
+ document.body.appendChild(this.$snowfield);
+
+ const updateSize = () => {
+ CopleSnow.winHeight = window.innerHeight;
+ CopleSnow.winWidth = window.innerWidth;
+ };
+ window.addEventListener("resize", updateSize);
+ (this as any)._resizeHandler = updateSize;
+
+ this.$snowfield.addEventListener(CopleSnow.transitionEndEvent, e => {
+ const snowflake = e.target as HTMLElement;
+ if (snowflake.classList.contains("snowflake")) {
+ this.$snowfield.removeChild(snowflake);
+ this.queue.push(snowflake);
+ }
+ });
+
+ const handleVisibilityChange = () => {
+ if (document.hidden) this.stop();
+ else this.play();
+ };
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+ (this as any)._visibilityHandler = handleVisibilityChange;
+
+ if (this.options.autoplay) this.play();
+ }
+
+ private createSnowflake(): HTMLElement {
+ const { type, content } = this.options as { type: "text" | "image" | "solid"; content: string | string[]; };
+ const cntLength = Array.isArray(content) ? content.length : 1;
+
+ let snowflake: HTMLElement;
+
+ if (type === "image") {
+ snowflake = document.createElement("img");
+ const src = typeof content === "string"
+ ? content
+ : content[Math.floor(Math.random() * cntLength)];
+ (snowflake as HTMLImageElement).src = src;
+ snowflake.setAttribute("draggable", "false");
+ } else {
+ snowflake = document.createElement("div");
+ if (type === "text") {
+ const textContent = typeof content === "string"
+ ? content
+ : content[Math.floor(Math.random() * cntLength)];
+ snowflake.textContent = textContent;
+ }
+ // if type is solid we don't need to set content
+ }
+
+ snowflake.className = `snowflake snowflake-${type}`;
+ (snowflake as any).dataset.type = type;
+
+ return snowflake;
+ }
+
+ private animateSnowflake() {
+ const { winWidth, winHeight } = CopleSnow;
+ const size = CopleSnow.random(this.options.minSize, this.options.maxSize);
+ const top = -2 * size;
+ const left = CopleSnow.random(0, winWidth - size);
+ const opacity = CopleSnow.random(5, 10) / 10;
+ const angle = CopleSnow.random(0, winHeight * 0.8, 1);
+ const translateX = CopleSnow.random(-100, 100);
+ const translateY = winHeight + size * 2;
+ const baseSpeed = settings.store.speed;
+ const duration = (winHeight * 10) / (baseSpeed / 50);
+ let snowflake: HTMLElement;
+ if (this.queue.length > 0) {
+ const reused = this.queue.shift()!;
+ if ((reused.dataset.type as string) !== this.options.type) {
+ snowflake = this.createSnowflake();
+ } else {
+ snowflake = reused;
+ if (this.options.type === "text") {
+ const { content } = this.options;
+ if (Array.isArray(content)) {
+ snowflake.textContent = content[Math.floor(Math.random() * content.length)];
+ }
+ } else if (this.options.type === "image") {
+ const { content } = this.options;
+ if (Array.isArray(content)) {
+ (snowflake as HTMLImageElement).src = content[Math.floor(Math.random() * content.length)];
+ }
+ }
+ }
+ } else {
+ snowflake = this.createSnowflake();
+ }
+
+ const styleRules: Record = {
+ top: `${top}px`,
+ left: `${left}px`,
+ opacity: opacity,
+ transform: "none",
+ transition: `${duration}ms linear`
+ };
+
+ switch (this.options.type) {
+ case "solid":
+ styleRules.width = styleRules.height = `${size}px`;
+ break;
+ case "text":
+ styleRules.fontSize = `${size}px`;
+ break;
+ case "image":
+ styleRules.width = `${size}px`;
+ styleRules.height = "auto";
+ break;
+ }
+
+ CopleSnow.setStyle(snowflake, styleRules);
+ this.$snowfield.appendChild(snowflake);
+
+ setTimeout(() => {
+ CopleSnow.setStyle(snowflake, {
+ transform: `translate(${translateX}px, ${translateY}px) rotate(${angle}deg)`,
+ opacity: this.options.fadeOut ? 0 : opacity
+ });
+ }, 100);
+ }
+
+ play() {
+ if (this.playing) return;
+ this.timer = window.setInterval(() => this.animateSnowflake(), this.options.interval);
+ this.playing = true;
+ }
+
+ stop() {
+ if (!this.playing) return;
+ if (this.timer !== null) {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
+ this.playing = false;
+ }
+
+ destroy() {
+ this.stop();
+ if (this.$snowfield.parentNode) {
+ this.$snowfield.remove();
+ }
+ window.removeEventListener("resize", (this as any)._resizeHandler);
+ document.removeEventListener("visibilitychange", (this as any)._visibilityHandler);
+ }
+
+ updateOptions(newOptions: Partial) {
+ Object.assign(this.options, newOptions);
+ }
+}
+
+const SnowfallManager: React.FC = () => {
+ const snowRef = React.useRef(null);
+ const styleRef = React.useRef(null);
+
+ React.useEffect(() => {
+ // Inject CSS
+ const styleEl = document.createElement("style");
+ styleEl.id = "snowfall-styles";
+ styleEl.textContent = SnowfallCSS;
+ document.head.appendChild(styleEl);
+ styleRef.current = styleEl;
+
+ // Get initial snow type from settings
+ const snowType = settings.store.typeOfSnow as "text" | "solid" | "image";
+ const snowOptions: Partial = {
+ autoplay: false,
+ type: snowType,
+ maxSize: settings.store.maxSize,
+ interval: 1000 / settings.store.flakesPerSecond
+ };
+
+ snowOptions.interval = settings.store.speed;
+
+ // Set content based on type
+ if (snowType === "image") {
+ snowOptions.content = SNOWFLAKE_SVGS;
+ }
+
+ // Create snow instance with settings
+ const snow = new CopleSnow(snowOptions);
+ snowRef.current = snow;
+
+ const blurHandler = () => snow.stop();
+ const focusHandler = () => {
+ if (document.hasFocus()) snow.play();
+ };
+
+ window.addEventListener("blur", blurHandler);
+ window.addEventListener("focus", focusHandler);
+
+ if (document.hasFocus()) {
+ snow.play();
+ }
+
+ // Custom listener for settings changes (we don't have a built-in one)
+ let lastSettings = {
+ type: settings.store.typeOfSnow as "text" | "solid" | "image",
+ maxSize: settings.store.maxSize,
+ speed: settings.store.speed,
+ flakesPerSecond: settings.store.flakesPerSecond
+ };
+
+ const settingsInterval = setInterval(() => {
+ const newSettings = {
+ type: settings.store.typeOfSnow as "text" | "solid" | "image",
+ maxSize: settings.store.maxSize,
+ speed: settings.store.speed,
+ flakesPerSecond: settings.store.flakesPerSecond
+ };
+
+ if (Object.keys(newSettings).some(k => newSettings[k as keyof typeof newSettings] !== lastSettings[k as keyof typeof lastSettings])) {
+ lastSettings = newSettings;
+
+ const updateOptions: Partial = {
+ type: newSettings.type,
+ maxSize: newSettings.maxSize,
+ interval: 1000 / newSettings.flakesPerSecond
+ };
+
+ if (newSettings.type === "image") updateOptions.content = SNOWFLAKE_SVGS;
+ else if (newSettings.type === "text") updateOptions.content = "❄";
+
+ snow.updateOptions(updateOptions);
+ }
+ }, 5000);
+
+ return () => {
+ snow.destroy();
+ snowRef.current = null;
+
+ window.removeEventListener("blur", blurHandler);
+ window.removeEventListener("focus", focusHandler);
+
+ clearInterval(settingsInterval);
+
+ if (styleEl.parentNode) {
+ styleEl.remove();
+ }
+ styleRef.current = null;
+
+ const snowfield = document.getElementById("snowfield");
+ if (snowfield) snowfield.remove();
+ };
+ }, []);
+
+ return null;
+};
+
+let snowRoot: Root | null = null;
+let container: HTMLDivElement | null = null;
+
+export default definePlugin({
+ name: "Snowfall",
+ description: "Let it snow on Discord! Ported from the BetterDiscord plugin by square.",
+ authors: [EquicordDevs.ZcraftElite, EquicordDevs.square],
+
+ settingsAboutComponent: () => (
+ <>
+ Information
+
+ This plugin adds a christmas-y snowfall effect on top of Discord's interface.
+ You can change the type of snow in the settings below.
+
+ NOTE: While on most computers this plugin will not impact performance any more than your average Equicord extension,
+ it may cause some lag on lower end systems.
+
+ >
+ ),
+
+
+ settings,
+
+ start() {
+ container = document.createElement("div");
+ container.id = "snowfall-plugin-container";
+ document.body.appendChild(container);
+
+ snowRoot = createRoot(container);
+ snowRoot.render(React.createElement(SnowfallManager));
+ },
+
+ stop() {
+ if (snowRoot) {
+ snowRoot.unmount();
+ snowRoot = null;
+ }
+ if (container) {
+ if (container.parentNode) container.remove();
+ container = null;
+ }
+ }
+});
diff --git a/src/plugins/devCompanion.dev/initWs.tsx b/src/plugins/devCompanion.dev/initWs.tsx
index da1c9a9f0..4221a9fb7 100644
--- a/src/plugins/devCompanion.dev/initWs.tsx
+++ b/src/plugins/devCompanion.dev/initWs.tsx
@@ -143,6 +143,7 @@ export function initWs(isManual = false) {
case "disable": {
const m = d.data;
const settings = Settings.plugins[m.pluginName];
+ if (!settings) throw new Error("Plugin not found: " + m.pluginName);
if (m.enabled !== settings.enabled)
toggleEnabled(m.pluginName, reply);
break;
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index d18322d3c..a996c6052 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -1186,6 +1186,10 @@ export const EquicordDevs = Object.freeze({
name: "Prism",
id: 390884143749136386n
},
+ square: {
+ name: "square",
+ id: 219363409097916416n
+ }
} satisfies Record);
// iife so #__PURE__ works correctly