Sigmally Fixes V2

Easily 10X your FPS on Sigmally.com + many bug fixes + great for multiboxing + supports SigMod

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Sigmally Fixes V2
// @version      2.8.1
// @description  Easily 10X your FPS on Sigmally.com + many bug fixes + great for multiboxing + supports SigMod
// @author       8y8x
// @match        https://*.sigmally.com/*
// @grant        none
// @license      MIT
// @icon         https://raw.githubusercontent.com/8y8x/sigmally-fixes/refs/heads/main/icon.png
// @compatible   chrome
// @compatible   opera
// @compatible   edge
// @namespace https://8y8x.dev/sigmally-fixes
// ==/UserScript==

/* eslint
	camelcase: 'error',
	comma-dangle: ['error', 'always-multiline'],
	indent: ['error', 'tab', { SwitchCase: 1 }],
	max-len: ['error', { code: 120 }],
	no-console: ['error', { allow: ['warn', 'error'] }],
	no-trailing-spaces: 'error',
	quotes: ['error', 'single'],
	semi: 'error',
*/ // a light eslint configuration that doesn't compromise code quality
'use strict';

(() => {
	const sfVersion = '2.8.1';
	const { Infinity, undefined } = window; // yes, this actually makes a significant difference

	////////////////////////////////
	// Define Auxiliary Functions //
	////////////////////////////////
	const aux = (() => {
		const aux = {};

		/** @type {Map<string, string>} */
		aux.clans = new Map();
		const fetchClans = () => {
			fetch('https://sigmally.com/api/clans').then(r => r.json()).then(r => {
				if (r.status !== 'success') return setTimeout(fetchClans, 10_000);
				for (const clan of r.data) aux.clans.set(clan._id || '', clan.name || '');
				// does not need to be updated often, but just enough so people who leave their tab open don't miss out
				setTimeout(() => fetchClans(), 600_000);
			}).catch(() => setTimeout(fetchClans, 10_000));
		}
		fetchClans();

		/**
		 * @template T
		 * @param {T} x
		 * @param {string} err should be readable and easily translatable
		 * @returns {T extends (null | undefined | false | 0) ? never : T}
		 */
		aux.require = (x, err) => {
			if (!x) {
				prompt('[Sigmally Fixes]: ' + err, err); // use prompt, so people can paste the error message into google translate
				throw '[Sigmally Fixes]: ' + err;
			}

			return /** @type {any} */ (x);
		};

		const dominantColorCtx = aux.require(
			document.createElement('canvas').getContext('2d', { willReadFrequently: true }),
			'Unable to get 2D context for aux utilities. This is probably your browser being dumb, maybe reload ' +
			'the page?',
		);
		/**
		 * @param {HTMLImageElement} img
		 * @returns {[number, number, number, number]}
		 */
		aux.dominantColor = img => {
			dominantColorCtx.canvas.width = dominantColorCtx.canvas.height = 7;
			dominantColorCtx.drawImage(img, 0, 0, 7, 7);
			const data = dominantColorCtx.getImageData(0, 0, 7, 7);

			const r = [], g = [], b = [];
			let sumA = 0, numA = 0;
			for (let x = 0; x < 7; ++x) {
				for (let y = 0; y < 7; ++y) {
					const d2 = (3 - x) * (3 - x) + (3 - y) * (3 - y);
					if (d2 > 3 * 3) continue; // do not consider pixels outside a circle, as they may be blank
					const pixel = y * 7 + x;
					r.push(data.data[pixel * 4]);
					g.push(data.data[pixel * 4 + 1]);
					b.push(data.data[pixel * 4 + 2]);
					sumA += data.data[pixel * 4 + 3];
					++numA;
				}
			}

			r.sort();
			g.sort();
			b.sort();
			/** @type {[number, number, number, number]} */
			const color = [r[Math.ceil(r.length / 2)], g[Math.ceil(g.length / 2)], b[Math.ceil(b.length / 2)], 1];
			color[3] = (sumA / numA / 255) ** 4; // transparent skins should use the player color

			const max = Math.max(color[0], color[1], color[2]);
			if (max === 0) {
				color[0] = color[1] = color[2] = 1;
			} else {
				color[0] /= max;
				color[1] /= max;
				color[2] /= max;
			}

			return color;
		};

		/**
		 * consistent exponential easing relative to 60fps. this models "x += (targetX - x) * dt" scenarios.
		 * for example, with a factor of 2, o=0, n=1:
		 * - at 60fps, 0.5 is returned.
		 * - at 30fps (after 2 frames), 0.75 is returned.
		 * - at 15fps (after 4 frames), 0.875 is returned.
		 * - at 120fps, 0.292893 is returned. if you called this again with o=0.292893, n=1, you would get 0.5.
		 *
		 * @param {number} o
		 * @param {number} n
		 * @param {number} factor
		 * @param {number} dt in seconds
		 */
		aux.exponentialEase = (o, n, factor, dt) => {
			return o + (n - o) * (1 - (1 - 1 / factor) ** (60 * dt));
		};

		/** @param {KeyboardEvent | MouseEvent} e */
		aux.keybind = e => {
			if (!e.isTrusted) return undefined; // custom key events are usually missing properties

			if (e instanceof KeyboardEvent) {
				let keybind = e.key;
				keybind = keybind[0].toUpperCase() + keybind.slice(1); // capitalize first letter (e.g. Shift, R, /, ...)
				if (keybind === '+') keybind = '='; // ensure + can be used to split up keybinds
				if (e.ctrlKey) keybind = 'Ctrl+' + keybind;
				if (e.altKey) keybind = 'Alt+' + keybind;
				if (e.metaKey) keybind = 'Cmd+' + keybind;
				if (e.shiftKey) keybind = 'Shift' + keybind;
				return keybind;
			} else {
				return `Mouse${e.button}`;
			}
		};

		/**
		 * @param {string} hex
		 * @returns {[number, number, number, number]}
		 */
		aux.hex2rgba = hex => {
			switch (hex.length) {
				case 4: // #rgb
				case 5: // #rgba
					return [
						(parseInt(hex[1], 16) || 0) / 15,
						(parseInt(hex[2], 16) || 0) / 15,
						(parseInt(hex[3], 16) || 0) / 15,
						hex.length === 5 ? (parseInt(hex[4], 16) || 0) / 15 : 1,
					];
				case 7: // #rrggbb
				case 9: // #rrggbbaa
					return [
						(parseInt(hex.slice(1, 3), 16) || 0) / 255,
						(parseInt(hex.slice(3, 5), 16) || 0) / 255,
						(parseInt(hex.slice(5, 7), 16) || 0) / 255,
						hex.length === 9 ? (parseInt(hex.slice(7, 9), 16) || 0) / 255 : 1,
					];
				default:
					return [1, 1, 1, 1];
			}
		};

		/**
		 * @param {number} r
		 * @param {number} g
		 * @param {number} b
		 * @param {number} a
		 */
		aux.rgba2hex = (r, g, b, a) => {
			return [
				'#',
				Math.floor(r * 255).toString(16).padStart(2, '0'),
				Math.floor(g * 255).toString(16).padStart(2, '0'),
				Math.floor(b * 255).toString(16).padStart(2, '0'),
				Math.floor(a * 255).toString(16).padStart(2, '0'),
			].join('');
		};

		// i don't feel like making an awkward adjustment to aux.rgba2hex
		/**
		 * @param {number} r
		 * @param {number} g
		 * @param {number} b
		 * @param {any} _a
		 */
		aux.rgba2hex6 = (r, g, b, _a) => {
			return [
				'#',
				Math.floor(r * 255).toString(16).padStart(2, '0'),
				Math.floor(g * 255).toString(16).padStart(2, '0'),
				Math.floor(b * 255).toString(16).padStart(2, '0'),
			].join('');
		};

		/** @param {string} name */
		aux.parseName = name => {
			const match = name.match(/^\{(.*?)\}(.*)$/);
			if (match) return [match[2], aux.parseSkin(match[1])];
			else return [name];
		}

		/** @param {string} skin */
		aux.parseSkin = skin => {
			if (!skin) return skin;
			skin = skin.replace('1%', '').replace('2%', '').replace('3%', '');
			return '/static/skins/' + skin + '.png';
		};

		/**
		 * @param {DataView} dat
		 * @param {number} o
		 * @returns {[string, number]}
		 */
		aux.readZTString = (dat, o) => {
			if (dat.getUint8(o) === 0) return ['', o + 1]; // quick return for empty strings (there are a lot)
			const startOff = o;
			for (; o < dat.byteLength; ++o) {
				if (dat.getUint8(o) === 0) break;
			}

			return [aux.textDecoder.decode(new DataView(dat.buffer, startOff, o - startOff)), o + 1];
		};

		/**
		 * @param {string} selector
		 * @param {boolean} value
		 */
		aux.setting = (selector, value) => {
			/** @type {HTMLInputElement | null} */
			const el = document.querySelector(selector);
			return el ? el.checked : value;
		};

		/** @param {boolean} accessSigmod */
		const settings = accessSigmod => {
			try {
				// current skin is saved in localStorage
				aux.settings = JSON.parse(localStorage.getItem('settings') ?? '');
			} catch (_) {
				aux.settings = /** @type {any} */ ({});
			}

			// sigmod doesn't have a checkbox for dark theme, so we infer it from the custom map color
			const { mapColor } = accessSigmod ? sigmod.settings : {};
			if (mapColor) {
				aux.settings.darkTheme
					= mapColor ? (mapColor[0] < 0.6 && mapColor[1] < 0.6 && mapColor[2] < 0.6) : true;
			} else {
				aux.settings.darkTheme = aux.setting('input#darkTheme', true);
			}
			aux.settings.jellyPhysics = aux.setting('input#jellyPhysics', false);
			aux.settings.showBorder = aux.setting('input#showBorder', true);
			aux.settings.showClanmates = aux.setting('input#showClanmates', true);
			aux.settings.showGrid = aux.setting('input#showGrid', true);
			aux.settings.showMass = aux.setting('input#showMass', false);
			aux.settings.showMinimap = aux.setting('input#showMinimap', true);
			aux.settings.showSkins = aux.setting('input#showSkins', true);
			aux.settings.zoomout = aux.setting('input#moreZoom', true);
			return aux.settings;
		};

		/** @type {{ darkTheme: boolean, jellyPhysics: boolean, showBorder: boolean, showClanmates: boolean,
		 showGrid: boolean, showMass: boolean, showMinimap: boolean, showSkins: boolean, zoomout: boolean,
		 gamemode: any, skin: any }} */
		aux.settings = settings(false);
		setInterval(() => settings(true), 250);
		// apply saved gamemode because sigmally fixes connects before the main game even loads
		if (aux.settings?.gamemode) {
			/** @type {HTMLSelectElement | null} */
			const gamemode = document.querySelector('select#gamemode');
			if (gamemode) gamemode.value = aux.settings.gamemode;
		}

		aux.textEncoder = new TextEncoder();
		aux.textDecoder = new TextDecoder();

		const trimCtx = aux.require(
			document.createElement('canvas').getContext('2d'),
			'Unable to get 2D context for text utilities. This is probably your browser being dumb, maybe reload ' +
			'the page?',
		);
		trimCtx.font = '20px Ubuntu';
		/**
		 * trims text to a max of 250px at 20px font, same as vanilla sigmally
		 * @param {string} text
		 */
		aux.trim = text => {
			while (trimCtx.measureText(text).width > 250) text = text.slice(0, -1);
			return text;
		};

		// @ts-expect-error
		let handler = window.signOut;
		Object.defineProperty(window, 'signOut', {
			get: () => () => {
				aux.userData = undefined;
				return handler?.();
			},
			set: x => handler = x,
		});

		/** @type {object | undefined} */
		aux.userData = undefined;
		aux.oldFetch = /** @type {typeof fetch} */ (fetch.bind(window));
		Object.defineProperty(window, 'fetch', {
			value: function(url, data, ...args) {
				const urlString = String(url);
				// block game.js from attempting to go through captcha flow
				if (urlString.includes('/server/recaptcha/v3')) return new Promise(() => {});
				// game.js doesn't think we're connected to a server, we default to eu0 because that's the
				// default everywhere else
				if (urlString.includes('/userdata/')) url = urlString.replace('///', '//eu0.sigmally.com/server/');
				// sigmod must be properly initialized (client can't be null), otherwise it will error
				// and game.js will never get the account data
				if (urlString.includes('/server/auth')) sigmod.patch();

				return aux.oldFetch(url, data, ...args).then(res => Object.defineProperty(res, 'json', {
					value: () => Response.prototype.json.call(res).then(obj => {
						if (obj?.body?.user) aux.userData = obj.body.user;
						return obj;
					}),
				}));
			},
		});

		// get the latest game.js version whenever possible (in the past, players could not log in on an older game.js)
		fetch('https://one.sigmally.com/assets/js/game.js', { cache: 'reload' });
		// clicking "continue" immediately makes a request for user data, so we can get it even if sigfixes runs late
		/** @type {HTMLButtonElement | null} */ (document.querySelector('#continue_button'))?.click();

		// the iconic Ubuntu font was replaced with Arial for some stupid reason, no one wants that
		const link = document.createElement('link');
		link.href = 'https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;700&display=swap';
		link.rel = 'stylesheet';
		document.head.appendChild(link);

		document.body.style.fontFamily = 'Ubuntu, Arial, sans-serif';

		return aux;
	})();



	////////////////////////
	// Destroy Old Client //
	////////////////////////
	(() => {
		const vanillaStack = () => {
			try {
				throw new Error();
			} catch (err) {
				// prevent drawing the game, but do NOT prevent saving settings (which is called on RQA)
				return err.stack.includes('/game.js') && !err.stack.includes('HTML');
			}
		};

		// #1 : kill the rendering process
		const oldRQA = requestAnimationFrame;
		window.requestAnimationFrame = fn => vanillaStack() ? -1 : oldRQA(fn);

		// #2 : kill access to using a WebSocket
		window.WebSocket = new Proxy(WebSocket, {
			construct(target, args) {
				if (args[0].includes('sigmally.com') && vanillaStack())
					throw new Error('[Sigmally Fixes]: Preventing new WebSocket() for unknown Sigmally connection');
				
				return new target(...args);
			}
		});

		const oldWsSend = WebSocket.prototype.send;
		WebSocket.prototype.send = function(x) {
			if (vanillaStack() && this.url.includes('sigmally.com')) {
				this.onclose = null; // prevent automatic reconnect
				this.close();
				throw new Error('[Sigmally Fixes]: Preventing WebSocket.prototype.send on unknown Sigmally connection');
			}

			return oldWsSend.call(this, x);
		}

		// #3 : prevent keys from being registered by the vanilla game
		setInterval(() => onkeydown = onkeyup = null, 200);
	})();

	//////////////////////////////////
	// Apply Complex SigMod Patches //
	//////////////////////////////////
	const sigmod = (() => {
		const sigmod = {};

		/** @type {{
		 * 	cellColor?: [number, number, number, number],
		 * 	doubleKey?: string,
		 * 	fixedLineKey?: string,
		 * 	foodColor?: [number, number, number, number],
		 * 	font?: string,
		 * 	horizontalLineKey?: string,
		 * 	mapColor?: [number, number, number, number],
		 * 	nameColor1?: [number, number, number, number],
		 * 	nameColor2?: [number, number, number, number],
		 * 	outlineColor?: [number, number, number, number],
		 * 	quadKey?: string,
		 * 	rapidFeedKey?: string,
		 * 	removeOutlines?: boolean,
		 * 	respawnKey?: string,
		 * 	showNames?: boolean,
		 * 	tripleKey?: string,
		 * 	verticalLineKey?: string,
		 * 	virusImage?: string,
		 * }} */
		sigmod.settings = {};
		/** @type {Set<string>} */
		const loadedFonts = new Set();
		setInterval(() => {
			// @ts-expect-error
			const real = window.sigmod?.settings;
			if (!real) return;
			sigmod.exists = true;

			const applyColor = (prop, initial, lookups) => {
				for (const lookup of lookups) {
					if (lookup && lookup !== initial) {
						sigmod.settings[prop] = aux.hex2rgba(lookup);
						return;
					}
				}
				sigmod.settings[prop] = undefined;
			};
			applyColor('cellColor', null, [real.game?.cellColor]);
			applyColor('foodColor', null, [real.game?.foodColor]);
			applyColor('mapColor', null, [real.game?.map?.color, real.mapColor]);
			// note: singular nameColor takes priority
			const name = real.game?.name;
			applyColor('nameColor1', '#ffffff', [name?.color, name?.gradient?.enabled && name.gradient.left]);
			applyColor('nameColor2', '#ffffff', [name?.color, name?.gradient?.enabled && name.gradient.right]);
			// sigmod treats the map border as cell borders for some reason
			applyColor('outlineColor', '#0000ff', [real.game?.borderColor]);
			sigmod.settings.removeOutlines = real.game?.removeOutlines;
			sigmod.settings.virusImage = real.game?.virusImage;
			sigmod.settings.rapidFeedKey = real.macros?.keys?.rapidFeed;
			// sigmod's showNames setting is always "true" interally (i think??)
			sigmod.settings.showNames = aux.setting('input#showNames', true);

			sigmod.settings.font = real.game?.font ?? 'Ubuntu';

			// sigmod does not download the bold variants of fonts, so we have to do that ourselves
			if (sigmod.settings.font && !loadedFonts.has(sigmod.settings.font)) {
				loadedFonts.add(sigmod.settings.font);
				const link = document.createElement('link');
				link.href = `https://fonts.googleapis.com/css2?family=${sigmod.settings.font}:wght@700&display=swap`;
				link.rel = 'stylesheet';
				document.head.appendChild(link);
			}
		}, 200);

		// patch sigmod when it's ready; typically sigmod loads first, but i can't guarantee that
		sigmod.exists = false;
		/** @type {((dat: DataView) => void) | undefined} */
		sigmod.handleMessage = undefined;
		let patchInterval;
		sigmod.patch = () => {
			const real = /** @type {any} */ (window).sigmod;
			if (!real || patchInterval === undefined) return;
			sigmod.exists = true;

			clearInterval(patchInterval);
			patchInterval = undefined;

			// anchor chat and minimap to the screen, so scrolling to zoom doesn't move them
			// it's possible that cursed will change something at any time so i'm being safe here
			const minimapContainer = /** @type {HTMLElement | null} */ (document.querySelector('.minimapContainer'));
			if (minimapContainer) minimapContainer.style.position = 'fixed';
			const modChat = /** @type {HTMLElement | null} */ (document.querySelector('.modChat'));
			if (modChat) modChat.style.position = 'fixed';

			// sigmod keeps track of the # of displayed messages with a counter, but it doesn't reset on clear
			// therefore, if the chat gets cleared with 200 (the maximum) messages in it, it will stay permanently*
			// blank
			const modMessages = /** @type {HTMLElement | null} */ (document.querySelector('#mod-messages'));
			if (modMessages) {
				const old = modMessages.removeChild;
				modMessages.removeChild = node => {
					if (modMessages.children.length > 200) return old.call(modMessages, node);
					else return node;
				};
			}

			// disable all keys on sigmod's end and enable them here
			/** @param {keyof typeof sigmod.settings} key */
			const getset = key => ({
				// toJSON and toString are implemented so that the key still displays and saves properly,
				// while returning an object that would never match anything in a keydown event
				get: () => ({ toJSON: () => sigmod.settings[key], toString: () => sigmod.settings[key] }),
				set: x => sigmod.settings[key] = x,
			});
			const keys = real.settings?.macros?.keys;
			if (keys) {
				sigmod.settings.respawnKey = keys.respawn;
				Object.defineProperty(keys, 'respawn', getset('respawnKey'));

				if (keys.line) {
					sigmod.settings.horizontalLineKey = keys.line.horizontal;
					sigmod.settings.verticalLineKey = keys.line.vertical;
					sigmod.settings.fixedLineKey = keys.line.fixed;
					Object.defineProperties(keys.line, {
						horizontal: getset('horizontalLineKey'),
						vertical: getset('verticalLineKey'),
						fixed: getset('fixedLineKey'),
					});
				}

				if (keys.splits) {
					sigmod.settings.doubleKey = keys.splits.double;
					sigmod.settings.tripleKey = keys.splits.triple;
					sigmod.settings.quadKey = keys.splits.quad;
					Object.defineProperties(keys.splits, {
						double: getset('doubleKey'),
						triple: getset('tripleKey'),
						quad: getset('quadKey'),
					});
				}
			}

			// create a fake sigmally proxy for sigmod, which properly relays some packets (because SigMod does not
			// support one-tab technology). it should also fix chat bugs due to disconnects and stuff
			// we do this by hooking into the SigWsHandler object
			{
				/** @type {object | undefined} */
				let handler;
				const old = Function.prototype.bind;
				Function.prototype.bind = function(obj) {
					if (obj.constructor?.name === 'SigWsHandler') handler = obj;
					return old.call(this, obj);
				};
				new WebSocket('wss://255.255.255.255/sigmally.com?sigfix');
				Function.prototype.bind = old;
				// handler is expected to be a "SigWsHandler", but it might be something totally different
				if (!(handler && 'sendPacket' in handler) && !('handleMessage' in handler)) return;
				// first, set up the handshake (opcode not-really-a-shuffle)
				// handshake is reset to false on close (which may or may not happen immediately)
				Object.defineProperty(handler, 'handshake', { get: () => true, set: () => {} });
				handler.R = handler.C = new Uint8Array(256); // R and C are linked
				for (let i = 0; i < 256; ++i) handler.C[i] = i;

				sigmod.handleMessage = dat => handler.handleMessage({ data: dat.buffer });
			}
		};
		patchInterval = setInterval(sigmod.patch, 500);
		sigmod.patch();

		return sigmod;
	})();

	/////////////////////////
	// Create Options Menu //
	/////////////////////////
	const settings = (() => {
		const settings = {
			autoZoom: true,
			background: '',
			blockBrowserKeybinds: false,
			blockNearbyRespawns: false,
			boldUi: false,
			/** @type {'natural' | 'default'} */
			camera: 'default',
			/** @type {'default' | 'instant'} */
			cameraMovement: 'default',
			cameraSmoothness: 2,
			cameraSpawnAnimation: true,
			cellGlow: false,
			cellOpacity: 1,
			cellOutlines: true,
			clans: false,
			clanScaleFactor: 1,
			colorUnderSkin: true,
			delayDouble: false,
			disableQuests: false,
			drawDelay: 120,
			jellySkinLag: true,
			massBold: false,
			massOpacity: 1,
			massScaleFactor: 1,
			mergeCamera: true,
			moveAfterLinesplit: false,
			multibox: '',
			/** @type {string[]} */
			multiNames: [],
			nameBold: false,
			nameScaleFactor: 1,
			outlineMulti: 0.2,
			// delta's default colors, #ff00aa and #ffffff
			outlineMultiColor: /** @type {[number, number, number, number]} */ ([1, 0, 2/3, 1]),
			outlineMultiInactiveColor: /** @type {[number, number, number, number]} */ ([1, 1, 1, 1]),
			pelletGlow: false,
			perftab: false,
			rainbowBorder: false,
			scrollFactor: 1,
			selfSkin: '',
			selfSkinMulti: '',
			slowerJellyPhysics: false,
			separateBoost: false,
			showStats: true,
			spectator: false,
			spectatorLatency: false,
			textOutlinesFactor: 1,
			// default is the default chat color
			theme: /** @type {[number, number, number, number]} */ ([252 / 255, 114 / 255, 0, 0]),
			tracer: false,
			unsplittableColor: /** @type {[number, number, number, number]} */ ([1, 1, 1, 1]),
		};

		const settingsExt = {};
		Object.setPrototypeOf(settings, settingsExt);

		try {
			Object.assign(settings, JSON.parse(localStorage.getItem('sigfix') ?? ''));
		} catch (_) { }

		// convert old settings
		{
			if (/** @type {any} */ (settings.multibox) === true) settings.multibox = 'Tab';
			else if (/** @type {any} */ (settings.multibox) === false) settings.multibox = '';

			if (/** @type {any} */ (settings).unsplittableOpacity !== undefined) {
				settings.unsplittableColor = [1, 1, 1, /** @type {any} */ (settings).unsplittableOpacity];
				delete settings.unsplittableOpacity;
			}

			const { autoZoom, multiCamera, synchronization } = /** @type {any} */ (settings);
			if (multiCamera !== undefined) {
				if (multiCamera === 'natural' || multiCamera === 'delta' || multiCamera === 'weighted') {
					settings.camera = 'natural';
				} else if (multiCamera === 'none') settings.camera = 'default';

				settings.mergeCamera = multiCamera !== 'weighted' && multiCamera !== 'none'; // the two-tab settings
				delete settings.multiCamera;
			}

			if (autoZoom === 'auto') settings.autoZoom = true;
			else if (autoZoom === 'never') settings.autoZoom = false;

			// 2.7.1: accidentally set the default to 'none' which sucks
			if (synchronization === 'none') settings.synchronization = 'flawless';
		}

		/** @type {(() => void)[]} */
		const onSyncs = [];
		/** @type {(() => void)[]} */
		const onUpdates = [];

		settingsExt.refresh = () => {
			onSyncs.forEach(fn => fn());
			onUpdates.forEach(fn => fn());
		};

		// allow syncing sigfixes settings in case you leave an extra sig tab open for a long time and would lose your
		// changed settings
		const channel = new BroadcastChannel('sigfix-settings');
		channel.addEventListener('message', msg => {
			Object.assign(settings, msg.data);
			settingsExt.refresh();
		});

		/** @type {IDBDatabase | undefined} */
		settingsExt.database = undefined;
		const dbReq = indexedDB.open('sigfix', 1);
		dbReq.addEventListener('success', () => void (settingsExt.database = dbReq.result));
		dbReq.addEventListener('upgradeneeded', () => {
			settingsExt.database = dbReq.result;
			settingsExt.database.createObjectStore('images');
		});

		// #1 : define helper functions
		/**
		 * @param {string} html
		 * @returns {HTMLElement}
		 */
		function fromHTML(html) {
			const div = document.createElement('div');
			div.innerHTML = html;
			return /** @type {HTMLElement} */ (div.firstElementChild);
		}

		settingsExt.save = () => {
			localStorage.setItem('sigfix', JSON.stringify(settings));
			channel.postMessage(settings);
			onUpdates.forEach(fn => fn());
		}

		/**
		 * @template O, T
		 * @typedef {{ [K in keyof O]: O[K] extends T ? K : never }[keyof O]} PropertyOfType
		 */

		/** @type {HTMLElement | null} */
		const vanillaModal = document.querySelector('#cm_modal__settings .ctrl-modal__modal');
		if (vanillaModal) vanillaModal.style.width = '440px'; // make modal wider to fit everything properly

		const vanillaMenu = document.querySelector('#cm_modal__settings .ctrl-modal__content');
		vanillaMenu?.appendChild(fromHTML(`<div class="menu__item">
			<div style="width: 100%; height: 1px; background: #bfbfbf;"></div>
		</div>`));

		/**
		 * @template T
		 * @param {T | null} el
		 * @returns {T}
		 */
		const require = el => /** @type {T} */ (el); // aux.require is unnecessary for requiring our own elements

		const vanillaContainer = document.createElement('div');
		vanillaContainer.className = 'menu__item';
		vanillaMenu?.appendChild(vanillaContainer);

		const sigmodContainer = document.createElement('div');
		sigmodContainer.className = 'mod_tab scroll';
		sigmodContainer.style.display = 'none';

		/** @type {{ container: HTMLElement, help: HTMLElement, helpbox: HTMLElement }[]} */
		const containers = [];

		/**
		 * @param {string} title
		 * @param {{ sigmod: HTMLElement, vanilla: HTMLElement }[]} components
		 * @param {() => boolean} show
		 * @param {string} help
		*/
		const setting = (title, components, show, help) => {
			const vanilla = fromHTML(`<div style="height: 25px; position: relative;">
				<div style="height: 25px; line-height: 25px; position: absolute; top: 0; left: 0;">
					<a id="sf-help" style="color: #0009; cursor: help; user-select: none;">(?)</a> ${title}
				</div>
				<div id="sf-components" style="height: 25px; margin-left: 5px; position: absolute; right: 0;
					bottom: 0;"></div>
				<div id="sf-helpbox" style="display: none; position: absolute; top: calc(100% + 5px); left: 20px;
					width: calc(100% - 30px); height: fit-content; padding: 10px; color: #000; background: #fff;
					border: 1px solid #999; border-radius: 4px; z-index: 2;">${help}</div>
			</div>`);
			const sigmod = fromHTML(`<div class="modRowItems justify-sb" style="padding: 5px 10px; position: relative;">
				<span><a id="sfsm-help" style="color: #fff9; cursor: help; user-select: none;">(?)</a> ${title}</span>
				<span class="justify-sb" id="sfsm-components"></span>
				<div id="sfsm-helpbox" style="display: none; position: absolute; top: calc(100% + 5px); left: 30px;
					width: calc(100% - 40px); height: fit-content; padding: 10px; color: #fffe; background: #000;
					border: 1px solid #6871f1; border-radius: 4px; z-index: 2;">${help}</div>
			</div>`);

			const vanillaComponents = require(vanilla.querySelector('#sf-components'));
			const sigmodComponents = require(sigmod.querySelector('#sfsm-components'));
			for (const pair of components) {
				vanillaComponents.appendChild(pair.vanilla);
				sigmodComponents.appendChild(pair.sigmod);
			}

			const reshow = () => void (vanilla.style.display = sigmod.style.display = show() ? '' : 'none');
			reshow();
			onUpdates.push(reshow);

			vanillaContainer.appendChild(vanilla);
			sigmodContainer.appendChild(sigmod);
			containers.push({
				container: vanilla,
				help: require(vanilla.querySelector('#sf-help')),
				helpbox: require(vanilla.querySelector('#sf-helpbox')),
			}, {
				container: sigmod,
				help: require(sigmod.querySelector('#sfsm-help')),
				helpbox: require(sigmod.querySelector('#sfsm-helpbox')),
			});
		};

		/**
		 * @param {PropertyOfType<typeof settings, number>} property
		 * @param {number} initial
		 * @param {number} min
		 * @param {number} max
		 * @param {number} step
		 * @param {number} decimals
		 */
		const slider = (property, initial, min, max, step, decimals) => {
			const listen = (slider, display) => {
				const change = () => slider.value = display.value = settings[property].toFixed(decimals);
				onSyncs.push(change);
				change();

				const onInput = input => {
					const value = Number(input.value);
					if (Number.isNaN(value)) return;

					settings[property] = value;
					change();
					settingsExt.save();
				};
				slider.addEventListener('input', () => onInput(slider));
				display.addEventListener('change', () => onInput(display));
			};

			const datalist = `<datalist id="sf-${property}-markers"> <option value="${initial}"></option> </datalist>`;
			const vanilla = fromHTML(`<div>
				<input id="sf-${property}" style="display: block; float: left; height: 25px; line-height: 25px;
					margin-left: 5px;" min="${min}" max="${max}" step="${step}" value="${initial}"
					list="sf-${property}-markers" type="range" />
				${initial !== undefined ? datalist : ''}
				<input id="sf-${property}-display" style="display: block; float: left; height: 25px;
					line-height: 25px; width: 50px; text-align: center; margin-left: 5px;" />
			</div>`);
			const sigmod = fromHTML(`<span class="justify-sb">
				<input id="sfsm-${property}" style="width: 200px;" type="range" min="${min}" max="${max}"
					step="${step}" value="${initial}" list="sf-${property}-markers" />
				${initial !== undefined ? datalist : ''}
				<input id="sfsm-${property}-display" class="text-center form-control" style="border: none;
					width: 50px; margin: 0 15px;" />
			</span>`);

			listen(require(vanilla.querySelector(`#sf-${property}`)),
				require(vanilla.querySelector(`#sf-${property}-display`)));
			listen(require(sigmod.querySelector(`#sfsm-${property}`)),
				require(sigmod.querySelector(`#sfsm-${property}-display`)));
			return { sigmod, vanilla };
		};

		/** @param {PropertyOfType<typeof settings, boolean>} property */
		const checkbox = property => {
			/** @param {HTMLInputElement} input */
			const listen = input => {
				onSyncs.push(() => input.checked = settings[property]);
				input.checked = settings[property];

				input.addEventListener('input', () => {
					settings[property] = input.checked;
					settingsExt.save();
				});
			};

			const vanilla = fromHTML(`<input id="sf-${property}" type="checkbox" />`);
			const sigmod = fromHTML(`<div style="margin-right: 25px;">
				<div class="modCheckbox" style="display: inline-block;">
					<input id="sfsm-${property}" type="checkbox" />
					<label class="cbx" for="sfsm-${property}"></label>
				</div>
			</div>`);
			listen(/** @type {HTMLInputElement} */ (vanilla));
			listen(require(sigmod.querySelector(`#sfsm-${property}`)));
			return { sigmod, vanilla };
		};

		/**
		 * @param {any} property
		 * @param {any} parent
		 * @param {string} key
		 */
		const image = (property, parent = settings, key = property) => {
			const listen = (input, isSigmod) => {
				onSyncs.push(() => input.value = parent[property]);
				input.value = parent[property];

				input.addEventListener('input', e => {
					if (input.value.startsWith('🖼️')) {
						input.value = parent[property];
						e.preventDefault();
						return;
					}

					/** @type {string} */ (parent[property]) = input.value;
					settingsExt.save();
				});
				input.addEventListener('dragenter', () => void (input.style.borderColor = '#00ccff'));
				input.addEventListener('dragleave',
					() => void (input.style.borderColor = isSigmod ? 'transparent' : ''));
				input.addEventListener('drop', e => {
					input.style.borderColor = isSigmod ? 'transparent' : '';
					e.preventDefault();

					const file = e.dataTransfer?.files[0];
					const { database } = settingsExt;
					if (!file || !database) return;

					input.value = '(importing)';

					const transaction = database.transaction('images', 'readwrite');
					transaction.objectStore('images').put(file, key);

					transaction.addEventListener('complete', () => {
						/** @type {string} */ (parent[property]) = input.value = `🖼️ ${file.name}`;
						settingsExt.save();
						render.resetCache(img => img.type === 'local');
					});
					transaction.addEventListener('error', err => {
						input.value = '(failed to load image)';
						console.warn('sigfix database error:', err);
					});
				});
			};

			const placeholder = 'https://i.imgur.com/... or drag here';
			const vanilla = fromHTML(`<input id="sf-${property}" placeholder="${placeholder}" type="text" />`);
			const sigmod = fromHTML(`<input class="modInput" id="sfsm-${property}" placeholder="${placeholder}"
				style="border: 1px solid transparent; width: 250px;" type="text" />`);
			listen(/** @type {HTMLInputElement} */ (vanilla), false);
			listen(/** @type {HTMLInputElement} */ (sigmod), true);
			return { sigmod, vanilla };
		};

		/** @param {PropertyOfType<typeof settings, [number, number, number, number]>} property */
		const color = (property, toggle = false) => {
			const listen = (input, alpha) => {
				const update = () => {
					input.value = aux.rgba2hex6(...settings[property]);
					if (toggle) alpha.checked = settings[property][3] > 0;
					else alpha.value = String(settings[property][3]);
				};
				onSyncs.push(update);
				update();

				const changed = () => {
					settings[property] = aux.hex2rgba(input.value);
					if (toggle) settings[property][3] = alpha.checked ? 1 : 0;
					else settings[property][3] = Number(alpha.value);
					settingsExt.save();
				};
				input.addEventListener('input', changed);
				alpha.addEventListener('input', changed);
			};

			const vanilla = fromHTML(`<div>
				<input id="sf-${property}-alpha" type="${toggle ? 'checkbox' : 'range'}" min="0" max="1" \
					step="0.01" ${toggle ? '' : 'style="width: 100px;"'} />
				<input id="sf-${property}" type="color" />
			</div>`);
			const sigmod = fromHTML(`<div style="margin-right: 25px;">
				${toggle ? `<div class="modCheckbox" style="display: inline-block;">
					<input id="sfsm-${property}-alpha" type="checkbox" />
					<label class="cbx" for="sfsm-${property}-alpha"></label>
				</div>` : `<input id="sfsm-${property}-alpha" type="range" min="0" max="1" step="0.01" \
					style="width: 100px" />`}
				<input id="sfsm-${property}" type="color" />
			</div>`);
			listen(require(vanilla.querySelector(`#sf-${property}`)),
				require(vanilla.querySelector(`#sf-${property}-alpha`)));
			listen(require(sigmod.querySelector(`#sfsm-${property}`)),
				require(sigmod.querySelector(`#sfsm-${property}-alpha`)));
			return { sigmod, vanilla };
		};

		/**
		 * @param {PropertyOfType<typeof settings, string>} property
		 * @param {[string, string][]} options
		 */
		const dropdown = (property, options) => {
			const listen = input => {
				onSyncs.push(() => input.value = settings[property]);
				input.value = settings[property];

				input.addEventListener('input', () => {
					/** @type {string} */ (settings[property]) = input.value;
					settingsExt.save();
				});
			};

			const vanilla = fromHTML(`\<select id="sf-${property}">
				${options.map(([value, name]) => `<option value="${value}">${name}</option>`).join('\n')}
			</select>`);
			const sigmod = fromHTML(`<select class="form-control" id="sfsm-${property}" style="width: 250px;">
				${options.map(([value, name]) => `<option value="${value}">${name}</option>`).join('\n')}
			</select>`);
			listen(/** @type {HTMLSelectElement} */ (vanilla));
			listen(/** @type {HTMLSelectElement} */ (sigmod));
			return { sigmod, vanilla };
		};

		/**
		 * @param {any} property
		 * @param {any} parent
		 */
		const keybind = (property, parent = settings) => {
			/** @param {HTMLInputElement} input */
			const listen = input => {
				onSyncs.push(() => input.value = parent[property]);
				input.value = parent[property];

				input.addEventListener('keydown', e => {
					if (e.key === 'Control' || e.key === 'Alt' || e.key === 'Meta') return;
					if (e.code === 'Escape' || e.code === 'Backspace') {
						parent[property] = input.value = '';
					} else {
						parent[property] = input.value = aux.keybind(e) ?? '';
					}
					settingsExt.save();
					e.preventDefault(); // prevent the key being typed in
				});
				input.addEventListener('mousedown', e => {
					if (e.button === 0 && document.activeElement !== input) return; // do default action
					parent[property] = input.value = aux.keybind(e) ?? '';
					settingsExt.save();
					e.preventDefault();
				});
				input.addEventListener('contextmenu', e => void e.preventDefault());
			};

			const vanilla = fromHTML(`<input id="sf-${property}" placeholder="..." type="text" style="
				text-align: center; width: 80px;" />`);
			const sigmod = fromHTML(`<input class="keybinding" id="sfsm-${property}" placeholder="..."
				style="max-width: 100px; width: 100px;" type="text" />`);
			listen(/** @type {HTMLInputElement} */ (vanilla));
			listen(/** @type {HTMLInputElement} */ (sigmod));
			return { sigmod, vanilla };
		};

		addEventListener('mousedown', ev => {
			for (const { container, help, helpbox } of containers) {
				if (container.contains(/** @type {Node | null} */ (ev.target))) {
					if (ev.target === help) helpbox.style.display = '';
				} else {
					if (helpbox.style.display === '') helpbox.style.display = 'none';
				}
			}
		});

		const separator = (text = '•') => {
			vanillaContainer.appendChild(fromHTML(`<div style="text-align: center; width: 100%;">${text}</div>`));
			sigmodContainer.appendChild(fromHTML(`<span class="text-center">${text}</span>`));
		};

		const newTag = `<span style="padding: 2px 5px; border-radius: 10px; background: #76f; color: #fff;
			font-weight: bold; font-size: 0.95rem; user-select: none;">NEW</span>`;

		// #2 : generate ui for settings
		setting('Draw delay', [slider('drawDelay', 120, 40, 300, 1, 0)], () => true,
			'How long (in milliseconds) cells will lag behind for. Lower values mean cells will very quickly catch ' +
			'up to where they actually are.');
		setting('Cell outlines', [checkbox('cellOutlines')], () => true,
			'Whether the subtle dark outlines around cells (including skins) should draw.');
		setting('Cell opacity', [slider('cellOpacity', 1, 0, 1, 0.005, 3)], () => true,
			'How opaque cells should be. 1 = fully visible, 0 = invisible. It can be helpful to see the size of a ' +
			'smaller cell under a big cell.');
		setting('Self skin URL', [image('selfSkin')], () => true,
			'A custom skin for yourself. You can drag+drop a skin here, or use a direct URL. Not visible to others.');
		setting('Secondary skin URL', [image('selfSkinMulti')], () => !!settings.multibox,
			'A custom skin for your secondary multibox tab. You can drag+drop a skin here, or use a direct URL. Not ' +
			'visible to others.');
		setting('Map background', [image('background')], () => true,
			'A square background image to use within the entire map border. Images 512x512 and under will be treated ' +
			'as a repeating pattern, where 50 pixels = 1 grid square.');
		setting('Lines between cell and mouse', [checkbox('tracer')], () => true,
			'If enabled, draws tracers between all of the cells you ' +
			'control and your mouse. Useful as a hint to your subconscious about which tab you\'re currently on.');

		separator('• camera •');
		setting('Camera style', [dropdown('camera', [['natural', 'Natural (weighted)'], ['default', 'Default']])],
			() => true,
			'How the camera focuses on your cells. <br>' +
			'- A "natural" camera follows your center of mass. If you have a lot of small back pieces, they would ' +
			'barely affect your camera position. <br>' +
			'- The "default" camera focuses on every cell equally. If you have a lot of small back pieces, your ' +
			'camera would focus on those instead. <br>' +
			'When one-tab multiboxing, you <b>must</b> use the Natural (weighted) camera style.');
		setting('Camera movement',
			[dropdown('cameraMovement', [['default', 'Default'], ['instant', 'Instant']])], () => true,
			'How the camera moves. <br>' +
			'- "Default" camera movement follows your cell positions, but when a cell dies or splits, it immediately ' +
			'stops or starts focusing on it. Artificial smoothness is added - you can control that with the ' +
			'"Camera smoothness" setting. <br>' +
			'- "Instant" camera movement exactly follows your cells without lagging behind, gradually focusing more ' +
			'or less on cells while they split or die. There is no artificial smoothness, but you should use a ' +
			'higher draw delay (at least 100). You might find this significantly smoother than the default camera.');
		setting('Camera smoothness', [slider('cameraSmoothness', 2, 1, 10, 0.1, 1)],
			() => settings.cameraMovement === 'default',
			'How slowly the camera lags behind. The default is 2; using 4 moves the camera about twice as slowly, ' +
			'for example. Setting to 1 removes all camera smoothness.');
		setting('Zoom speed', [slider('scrollFactor', 1, 0.05, 1, 0.05, 2)], () => true,
			'A smaller zoom speed lets you fine-tune your zoom.');
		setting('Auto-zoom', [checkbox('autoZoom')], () => true,
			'When enabled, automatically zooms in/out for you based on how big you are.');
		setting('Move camera while spawning', [checkbox('cameraSpawnAnimation')], () => true,
			'When spawning, normally the camera will take a bit of time to move to where your cell spawned. This ' +
			'can be disabled.');

		separator('• multibox •');
		setting('Multibox keybind', [keybind('multibox')], () => true,
			'The key to press for switching multibox tabs. "Tab" is recommended, but you can also use "Ctrl+Tab" and ' +
			'most other keybinds.');
		setting('One-tab mode', [checkbox('mergeCamera')], () => !!settings.multibox,
			'When enabled, your camera will focus on both multibox tabs at once. Disable this if you prefer two-tab-' +
			'style multiboxing. <br>' +
			'When one-tab multiboxing, you <b>must</b> use the Natural (weighted) camera style.');
		setting('Multibox outline thickness', [slider('outlineMulti', 0.2, 0, 1, 0.01, 2)],
			() => !!settings.multibox,
			'When multiboxing, rings appear on your cells, the thickness being a % of your cell radius. This only ' +
			'shows when you\'re near one of your tabs.');
		setting('Current tab outline color', [color('outlineMultiColor')], () => !!settings.multibox,
			'The color of the rings around your current multibox tab. Only shown when near another tab. The slider ' +
			'is the outline opacity.');
		setting('Other tab outline color', [color('outlineMultiInactiveColor')], () => !!settings.multibox,
			'The color of the rings around your other inactive multibox tabs. Only shown when near another tab. The ' +
			'slider is the outline opacity.');
		setting('Block respawns near other tabs', [checkbox('blockNearbyRespawns')], () => !!settings.multibox,
			'When enabled, the respawn key (using SigMod) will be disabled if your multibox tabs are close. ' +
			'This means you can spam the respawn key until your multibox tab spawns nearby.');

		separator('• text •');
		setting('Name scale factor', [slider('nameScaleFactor', 1, 0.5, 2, 0.01, 2)], () => true,
			'The size multiplier of names.');
		setting('Mass scale factor', [slider('massScaleFactor', 1, 0.5, 4, 0.01, 2)], () => true,
			'The size multiplier of mass (which is half the size of names).');
		setting('Mass opacity', [slider('massOpacity', 1, 0, 1, 0.01, 2)], () => true,
			'The opacity of the mass text. You might find it visually appealing to have mass be a little dimmer than ' +
			'names.');
		setting('Bold name / mass text', [checkbox('nameBold'), checkbox('massBold')], () => true,
			'Uses the bold Ubuntu font (like Agar.io) for names (left checkbox) or mass (right checkbox).');
		setting('Show clans', [checkbox('clans')], () => true,
			'When enabled, shows the name of the clan a player is in above their name. ' +
			'If you turn off names (using SigMod), then player names will be replaced with their clan\'s.');
		setting('Clan scale factor', [slider('clanScaleFactor', 1, 0.5, 4, 0.01, 2)], () => settings.clans,
			'The size multiplier of a player\'s clan displayed above their name. When names are off, names will be ' +
			'replaced with clans and use the name scale factor instead.');
		setting('Text outline thickness', [slider('textOutlinesFactor', 1, 0, 2, 0.01, 2)], () => true,
			'The multiplier of the thickness of the black stroke around names, mass, and clans on cells. You can set ' +
			'this to 0 to disable outlines AND text shadows.');

		separator('• other •');
		setting('Theme color', [color('theme', true)], () => true,
			'If enabled, uses this color for the minimap (and chat, if not using SigMod). It\'s a small detail that ' +
			'can make the game feel more immersive.');
		setting('Block all browser keybinds', [checkbox('blockBrowserKeybinds')], () => true,
			'When enabled, only F11 is allowed to be pressed when in fullscreen. Most other browser and system ' +
			'keybinds will be disabled.');
		setting('Unsplittable cell outline', [color('unsplittableColor')], () => true,
			'The color of the ring around cells that cannot split. The slider ');
		setting('Jelly physics skin size lag', [checkbox('jellySkinLag')], () => true,
			'Jelly physics causes cells to grow and shrink slower than text and skins, making the game more ' +
			'satisfying. If you have a skin that looks weird only with jelly physics, try turning this off.');
		setting('Slower jelly physics', [checkbox('slowerJellyPhysics')], () => true,
			'Sigmally Fixes normally speeds up the jelly physics animation for it to be tolerable when splitrunning. ' +
			'If you prefer how it was in the vanilla client (really slow but satisfying), enable this setting.');
		setting('Cell / pellet glow', [checkbox('cellGlow'), checkbox('pelletGlow')], () => true,
			'When enabled, gives cells or pellets a slight glow. Basically, shaders for Sigmally. This is very ' +
			'optimized and should not impact performance.');
		setting('Rainbow border', [checkbox('rainbowBorder')], () => true,
			'Gives the map a rainbow border. So shiny!!!');
		setting('Top UI uses bold text', [checkbox('boldUi')], () => true,
			'When enabled, the top-left score and stats UI and the leaderboard will use the bold Ubuntu font.');
		setting('Show server stats', [checkbox('showStats')], () => true,
			'When disabled, hides the top-left server stats including the player count and server uptime.');
		setting('Connect spectating tab', [checkbox('spectator')], () => true,
			'Automatically connects an extra tab and sets it to spectate #1.');
		setting('Show spectator tab ping', [checkbox('spectatorLatency')], () => settings.spectator,
			'When enabled, shows another ping measurement for your spectator tab.');
		setting('Separate XP boost from score', [checkbox('separateBoost')], () => true,
			'If you have an XP boost, your score in the top left will be doubled. If you don\'t want that, you can ' +
			'separate the XP boost from your score. For example "Score: 69420" becomes "Score: 34710 (X2)"');
		setting('Color under skin', [checkbox('colorUnderSkin')], () => true,
			'When disabled, transparent skins will be see-through and not show your cell color. Turn this off ' +
			'if using a bubble skin, for example.');
		setting('Move after linesplit', [checkbox('moveAfterLinesplit')], () => true,
			'When doing a horizontal or vertical linesplit, your position is frozen. With this setting enabled, you ' +
			'will begin moving forwards in that axis once you split, letting you go farther than normal.');
		setting(`Delay pushsplits`, [checkbox('delayDouble')], () => true,
			'When in 5+ cells, doing a doublesplit may cause your cells to go like { O∘∘ } and not { ∘∘∘∘ } - which ' +
			'is useful, but when using the doublesplit keybind, those small back pieces may go in front. ' +
			'When this setting is enabled, a 50ms delay will be added to the second split only when in 5+ cells, ' +
			'typically fixing the problem.');
		setting(`Ping variance ${newTag}`, [checkbox('perftab')], () => true,
			`Visualizes how delayed or early every game tick is, for each tab. Every game tick should be 40ms apart, so for example if two game ticks were 45ms apart or 35ms apart, you will see a variance of 5ms. <br>
			The graph is very sensitive, so <b>any</b> lag will be immediately visible. Some variance is normal.`);
		setting(`Disable daily challenges ${newTag}`, [checkbox('disableQuests')], () => true,
			'The food and playtime quests (or "daily challenges") send a lot of extra data (about 25% of sent ' +
			'packets). If you don\'t care about your daily challenges, you can turn these quests off. <br>' +
			'Note the other quests don\'t send any extra data so they can still be completed.');

		// #3 : create options for sigmod
		let sigmodInjection;
		sigmodInjection = setInterval(() => {
			const nav = document.querySelector('.mod_menu_navbar');
			const content = document.querySelector('.mod_menu_content');
			if (!nav || !content) return;

			clearInterval(sigmodInjection);
			content.appendChild(sigmodContainer);

			const navButton = fromHTML('<button class="mod_nav_btn">🔥 Sig Fixes</button>');
			nav.appendChild(navButton);
			navButton.addEventListener('click', () => {
				// basically openModTab() from sigmod
				(/** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.mod_tab'))).forEach(tab => {
					tab.style.opacity = '0';
					setTimeout(() => tab.style.display = 'none', 200);
				});

				(/** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.mod_nav_btn'))).forEach(tab => {
					tab.classList.remove('mod_selected');
				});

				navButton.classList.add('mod_selected');
				setTimeout(() => {
					sigmodContainer.style.display = 'flex';
					setTimeout(() => sigmodContainer.style.opacity = '1', 10);
				}, 200);
			});
		}, 100);

		return settings;
	})();

	/////////////////////
	// Prepare Game UI //
	/////////////////////
	const ui = (() => {
		const ui = {};

		(() => {
			const title = document.querySelector('#title');
			if (!title) return;

			const watermark = document.createElement('span');
			watermark.innerHTML = `<a href="https://greasyfork.org/scripts/483587/versions" \
				target="_blank">Sigmally Fixes ${sfVersion}</a> by yx`;
			if (sfVersion.includes('BETA')) {
				watermark.innerHTML += ' <br><a \
					href="https://raw.githubusercontent.com/8y8x/sigmally-fixes/refs/heads/main/sigmally-fixes.user.js"\
					target="_blank">[Update beta here]</a>';
			}
			title.insertAdjacentElement('afterend', watermark);

			// check if this version is problematic, don't do anything if this version is too new to be in versions.json
			// take care to ensure users can't be logged
			fetch('https://raw.githubusercontent.com/8y8x/sigmally-fixes/main/versions.json').then(res => res.json())
				.then(res => {
					if (!(sfVersion in res) || res[sfVersion].ok || !res[sfVersion].alert) return;
					const color = res[sfVersion].color || '#f00';
					const box = document.createElement('div');
					box.style.cssText = `background: ${color}3; border: 1px solid ${color}; width: 100%;
						height: fit-content; font-size: 1em; padding: 5px; margin: 5px 0; border-radius: 3px;
						color: ${color}`;
					box.innerHTML = String(res[sfVersion].alert)
						.replace(/\<|\>/g, '') // never allow html tag injection
						.replace(/\{link\}/g, '<a href="https://greasyfork.org/scripts/483587">[click here]</a>')
						.replace(/\{autolink\}/g, '<a href="\
							https://update.greasyfork.org/scripts/483587/Sigmally%20Fixes%20V2.user.js">\
							[click here]</a>');

					watermark.insertAdjacentElement('afterend', box);
				});
		})();

		ui.game = (() => {
			const game = {};

			/** @type {HTMLCanvasElement | null} */
			const oldCanvas = document.querySelector('canvas#canvas');
			if (!oldCanvas) throw 'exiting script - no canvas found';
			// leave the old canvas so the old client can actually run
			oldCanvas.style.display = 'none';

			const newCanvas = document.createElement('canvas');
			newCanvas.id = 'sf-canvas';
			newCanvas.style.cssText = `background: #003; width: 100vw; height: 100vh; position: fixed; top: 0; left: 0;
				z-index: 1;`;
			game.canvas = newCanvas;
			(document.querySelector('body div') ?? document.body).appendChild(newCanvas);

			// TODO: are these necessary anymore?
			// forward macro inputs from the canvas to the old one - this is for sigmod mouse button controls
			newCanvas.addEventListener('mousedown', e => oldCanvas.dispatchEvent(new MouseEvent('mousedown', e)));
			newCanvas.addEventListener('mouseup', e => oldCanvas.dispatchEvent(new MouseEvent('mouseup', e)));
			// forward mouse movements from the old canvas to the window - this is for sigmod keybinds that move
			// the mouse
			oldCanvas.addEventListener('mousemove', e => dispatchEvent(new MouseEvent('mousemove', e)));

			// when GPU acceleration is disabled, some devices (like my macbook) won't bother with webgl2, but others
			// (like my PC) will use a slow, software-based renderer that can't hold 60FPS
			const glGPUOnly = newCanvas.getContext('webgl2', { depth: false, failIfMajorPerformanceCaveat: true });
			const gl = aux.require(
				newCanvas.getContext('webgl2', { depth: false }),
				'Couldn\'t get WebGL2 context. Possible causes:\r\n' +
				'- Maybe GPU/Hardware acceleration needs to be enabled in your browser settings; \r\n' +
				'- Maybe your browser is just acting weird and it might fix itself after a restart; \r\n' +
				'- Maybe your GPU drivers are exceptionally old.',
			);
			if (!glGPUOnly) {
				const msg = 'WebGL2 failIfMajorPerformanceCaveat triggered: your GPU was not detected and the game ' +
					'will be laggy! Try enabling GPU/Hardware acceleration in your browser settings.';
				prompt(msg, msg); // not fatal, but something the user should really fix
			}
			game.gl = gl;

			// indicate that we will restore the context
			newCanvas.addEventListener('webglcontextlost', e => e.preventDefault()); 
			newCanvas.addEventListener('webglcontextrestored', () => {
				glconf.init();
				render.resetCache(() => true); // cleanup old caches (after render), can't do this within glconf.init()
			});

			const resize = () => {
				// devicePixelRatio does not have very high precision; it could be 0.800000011920929 for example
				newCanvas.width = Math.ceil(innerWidth * (devicePixelRatio - 0.0001));
				newCanvas.height = Math.ceil(innerHeight * (devicePixelRatio - 0.0001));
				game.gl.viewport(0, 0, newCanvas.width, newCanvas.height);
			}
			addEventListener('resize', resize);
			resize();

			return game;
		})();

		ui.stats = (() => {
			const container = document.createElement('div');
			container.style.cssText = 'position: fixed; top: 10px; left: 10px; width: 400px; height: fit-content; \
				user-select: none; z-index: 2; transform-origin: top left; font-family: Ubuntu;';
			document.body.appendChild(container);

			const score = document.createElement('div');
			score.style.cssText = 'font-size: 30px; color: #fff; line-height: 1.0;';
			container.appendChild(score);

			const measures = document.createElement('div');
			measures.style.cssText = 'font-size: 20px; color: #fff; line-height: 1.1;';
			container.appendChild(measures);

			const misc = document.createElement('div');
			// white-space: pre; allows using \r\n to insert line breaks
			misc.style.cssText = 'font-size: 14px; color: #fff; white-space: pre; line-height: 1.1; opacity: 0.5;';
			container.appendChild(misc);

			/** @param {symbol} view */
			const update = view => {
				const fontFamily = `"${sigmod.settings.font || 'Ubuntu'}", Ubuntu`;
				if (container.style.fontFamily !== fontFamily) container.style.fontFamily = fontFamily;

				const color = aux.settings.darkTheme ? '#fff' : '#000';
				score.style.color = color;
				measures.style.color = color;
				misc.style.color = color;

				score.style.fontWeight = measures.style.fontWeight = settings.boldUi ? 'bold' : 'normal';
				measures.style.opacity = settings.showStats ? '1' : '0.5';
				misc.style.opacity = settings.showStats ? '0.5' : '0';

				const scoreVal = world.score(world.selected);
				if (scoreVal > world.stats.highestScore) world.stats.highestScore = scoreVal;
				if (scoreVal <= 0) score.innerHTML = '';
				else if (typeof aux.userData?.boost === 'number' && Date.now() < aux.userData.boost) {
					if (settings.separateBoost)
						score.innerHTML = `Score: ${Math.floor(scoreVal)} <span style="color: #fc6;">(X2)</span>`;
					else
						score.innerHTML = `Score: ${Math.floor(scoreVal * 2)}`;
				} else score.innerHTML = `Score: ${Math.floor(scoreVal)}`;

				const con = net.connections.get(view);
				let measuresText = `${Math.floor(render.fps)} FPS`;
				if (con?.latency !== undefined) {
					measuresText += ` ${con.latency === -1 ? '????' : Math.floor(con.latency)}ms`;
					const spectateCon = net.connections.get(world.viewId.spectate);
					if (settings.spectatorLatency && spectateCon?.latency !== undefined) {
						measuresText
							+= ` (${spectateCon.latency === -1 ? '????' : Math.floor(spectateCon.latency)}ms)`;
					}
					measuresText += ' ping';
				}
				measures.textContent = measuresText;
			};

			/** @param {object | undefined} stats */
			const updateStats = (stats) => {
				if (!stats) {
					misc.textContent = '';
					return;
				}

				let uptime;
				if (stats.uptime < 60) {
					uptime = Math.floor(stats.uptime) + 's';
				} else {
					uptime = Math.floor(stats.uptime / 60 % 60) + 'min';
					if (stats.uptime >= 60 * 60)
						uptime = Math.floor(stats.uptime / 60 / 60 % 24) + 'hr ' + uptime;
					if (stats.uptime >= 24 * 60 * 60)
						uptime = Math.floor(stats.uptime / 24 / 60 / 60 % 60) + 'd ' + uptime;
				}

				misc.textContent = [
					`${stats.name} (${stats.gamemode})`,
					`${stats.external} / ${stats.limit} players`,
					// bots do not count towards .playing
					`${stats.playing} playing` + (stats.internal > 0 ? ` + ${stats.internal} bots` : ''),
					`${stats.spectating} spectating`,
					`${(stats.loadTime / 40 * 100).toFixed(1)}% load @ ${uptime}`,
				].join('\r\n');
			};

			/** @type {object | undefined} */
			let lastStats;
			setInterval(() => { // update as frequently as possible
				const currentStats = world.views.get(world.selected)?.stats;
				if (currentStats !== lastStats) updateStats(lastStats = currentStats);
			});

			return { update };
		})();

		ui.leaderboard = (() => {
			const container = document.createElement('div');
			container.style.cssText = 'position: fixed; top: 10px; right: 10px; width: 200px; height: fit-content; \
				user-select: none; z-index: 2; background: #0006; padding: 15px 5px; transform-origin: top right; \
				display: none;';
			document.body.appendChild(container);

			const title = document.createElement('div');
			title.style.cssText = 'font-family: Ubuntu; font-size: 30px; color: #fff; text-align: center; width: 100%;';
			title.textContent = 'Leaderboard';
			container.appendChild(title);

			const linesContainer = document.createElement('div');
			linesContainer.style.cssText = `font-family: Ubuntu; font-size: 20px; line-height: 1.2; width: 100%;
				height: fit-content; text-align: center; white-space: pre; overflow: hidden;`;
			container.appendChild(linesContainer);

			/** @type {HTMLDivElement[]} */
			const lines = [];
			/** @param {{ me: boolean, name: string, sub: boolean, place: number | undefined }[]} lb */
			const update = lb => {
				const fontFamily = `"${sigmod.settings.font || 'Ubuntu'}", Ubuntu`;
				if (linesContainer.style.fontFamily !== fontFamily)
					linesContainer.style.fontFamily = title.style.fontFamily = fontFamily;

				const friends = /** @type {any} */ (window).sigmod?.friend_names;
				const friendSettings = /** @type {any} */ (window).sigmod?.friends_settings;
				lb.forEach((entry, i) => {
					let line = lines[i];
					if (!line) {
						line = document.createElement('div');
						line.style.display = 'none';
						linesContainer.appendChild(line);
						lines.push(line);
					}

					line.style.display = 'block';
					line.textContent = `${entry.place ?? i + 1}. ${entry.name || 'An unnamed cell'}`;
					if (entry.me) line.style.color = '#faa';
					else if (friends instanceof Set && friends.has(entry.name) && friendSettings?.highlight_friends)
						line.style.color = friendSettings.highlight_color;
					else if (entry.sub) line.style.color = '#ffc826';
					else line.style.color = '#fff';
				});

				for (let i = lb.length; i < lines.length; ++i)
					lines[i].style.display = 'none';

				container.style.display = lb.length > 0 ? '' : 'none';
				container.style.fontWeight = settings.boldUi ? 'bold' : 'normal';
			}

			/** @type {object | undefined} */
			let lastLb;
			setInterval(() => { // update leaderboard frequently
				const currentLb = world.views.get(world.selected)?.leaderboard;
				if (currentLb !== lastLb) update((lastLb = currentLb) ?? []);
			});
		})();

		const mainMenu = aux.require(document.querySelector('#__line1')?.parentElement,
			'Can\'t find the main menu UI. Try reloading the page?');
		const statsContainer = aux.require(document.querySelector('#__line2'),
			'Can\'t find the death screen UI. Try reloading the page?');
		const continueButton = aux.require(document.querySelector('#continue_button'),
			'Can\'t find the continue button (on death). Try reloading the page?');

		const menuLinks = document.querySelector('#menu-links');
		const overlay = document.querySelector('#overlays');
		// sigmod uses this to detect if the menu is closed or not, otherwise this is unnecessary
		const menuWrapper = document.querySelector('#menu-wrapper');

		let escOverlayVisible = true;
		ui.escOverlayVisible = () => escOverlayVisible;
		/**
		 * @param {boolean} [show]
		 */
		ui.toggleEscOverlay = show => {
			escOverlayVisible = show ?? !escOverlayVisible;
			mainMenu.style.display = escOverlayVisible ? '' : 'none';
			if (overlay) overlay.style.display = escOverlayVisible ? '' : 'none';
			if (menuLinks) menuLinks.style.display = escOverlayVisible ? '' : 'none';
			if (menuWrapper) menuWrapper.style.display = escOverlayVisible ? '' : 'none';

			if (escOverlayVisible) ui.deathScreen.hide();
			ui.captcha.reposition();
		};

		ui.deathScreen = (() => {
			const deathScreen = {};
			let visible = false;

			continueButton.addEventListener('click', () => {
				ui.toggleEscOverlay(true);
				visible = false;
			});

			deathScreen.check = () => {
				if (world.stats.spawnedAt !== undefined && !world.alive()) deathScreen.show();
			};

			const bonus = document.querySelector('#menu__bonus');
			deathScreen.show = () => {
				const boost = typeof aux.userData?.boost === 'number' && aux.userData.boost > Date.now();
				if (bonus) {
					if (boost) {
						bonus.style.display = '';
						bonus.textContent = `Bonus score: ${Math.floor(world.stats.highestScore)}`;
					} else {
						bonus.style.display = 'none';
					}
				}

				const foodEatenElement = document.querySelector('#food_eaten');
				if (foodEatenElement) foodEatenElement.textContent = String(world.stats.foodEaten);

				const highestMassElement = document.querySelector('#highest_mass');
				if (highestMassElement)
					highestMassElement.textContent = String(Math.floor(world.stats.highestScore) * (boost ? 2 : 1));

				const highestPositionElement = document.querySelector('#top_leaderboard_position');
				if (highestPositionElement) highestPositionElement.textContent = String(world.stats.highestPosition);

				const timeAliveElement = document.querySelector('#time_alive');
				if (timeAliveElement) {
					const time = (performance.now() - (world.stats.spawnedAt ?? 0)) / 1000;
					const hours = Math.floor(time / 60 / 60);
					const mins = Math.floor(time / 60 % 60);
					const seconds = Math.floor(time % 60);

					timeAliveElement.textContent = `${hours ? hours + 'h' : ''} ${mins ? mins + 'm' : ''} `
						+ `${seconds ? seconds + 's' : ''}`;
				}

				statsContainer.classList.remove('line--hidden');
				visible = true;
				ui.toggleEscOverlay(false);
				if (overlay) overlay.style.display = '';
				world.stats = { foodEaten: 0, highestPosition: 200, highestScore: 0, spawnedAt: undefined };
				ui.captcha.reposition();
			};

			deathScreen.hide = () => {
				statsContainer?.classList.add('line--hidden');
				visible = false;
				// no need for ui.captcha.reposition() because the esc overlay will always be shown on deathScreen.hide
				// ads are managed by the game client
			};

			deathScreen.visible = () => visible;

			return deathScreen;
		})();

		ui.minimap = (() => {
			const canvas = document.createElement('canvas');
			canvas.style.cssText = 'position: fixed; bottom: 0; right: 0; background: #0006; width: 200px; \
				height: 200px; z-index: 2; user-select: none;';
			canvas.width = canvas.height = 200;
			document.body.appendChild(canvas);

			const ctx = aux.require(
				canvas.getContext('2d', { willReadFrequently: false }),
				'Unable to get 2D context for the minimap. This is probably your browser being dumb, maybe refresh?',
			);

			return { canvas, ctx };
		})();

		ui.chat = (() => {
			const chat = {};

			const block = aux.require(document.querySelector('#chat_block'), 'Can\'t find the chat UI, maybe refresh?');

			/**
			 * @param {ParentNode} root
			 * @param {string} selector
			 */
			function clone(root, selector) {
				/** @type {HTMLElement} */
				const old = aux.require(root.querySelector(selector), `Can't find this chat element: ${selector}.`);

				const el = /** @type {HTMLElement} */ (old.cloneNode(true));
				el.id = '';
				old.style.display = 'none';
				old.insertAdjacentElement('afterend', el);
				return el;
			}

			// can't just replace the chat box - otherwise sigmod can't hide it - so we make its children invisible
			// elements grabbed with clone() are only styled by their class, not id
			const toggle = clone(document, '#chat_vsbltyBtn');
			const scrollbar = clone(document, '#chat_scrollbar');
			const thumb = clone(scrollbar, '#chat_thumb');

			const input = chat.input = /** @type {HTMLInputElement} */ (aux.require(
				document.querySelector('#chat_textbox'),
				'Can\'t find the chat textbox. Try reloading the page?',
			));

			// allow zooming in/out on trackpad without moving the UI
			input.style.position = toggle.style.position = scrollbar.style.position = 'fixed';

			const list = document.createElement('div');
			list.style.cssText = 'width: 400px; height: 182px; position: fixed; bottom: 54px; left: 46px; \
				overflow: hidden; user-select: none; z-index: 301;';
			block.appendChild(list);

			let toggled = true;
			toggle.style.borderBottomLeftRadius = '10px'; // a bug fix :p
			toggle.addEventListener('click', () => {
				toggled = !toggled;
				input.style.display = toggled ? '' : 'none';
				scrollbar.style.display = toggled ? 'block' : 'none';
				list.style.display = toggled ? '' : 'none';
				toggle.style.borderTopRightRadius = toggle.style.borderBottomRightRadius = toggled ? '' : '10px';
				toggle.style.opacity = toggled ? '' : '0.25';
			});

			scrollbar.style.display = 'block';
			let scrollTop = 0; // keep a float here, because list.scrollTop is always casted to an int
			let thumbHeight = 1;
			let lastY;
			thumb.style.height = '182px';

			const updateThumb = () => {
				thumb.style.bottom = (1 - list.scrollTop / (list.scrollHeight - 182)) * (182 - thumbHeight) + 'px';
			};

			const scroll = () => {
				if (scrollTop >= list.scrollHeight - 182 - 40) { // close to bottom, snap downwards
					list.scrollTop = scrollTop = list.scrollHeight - 182;
				}

				thumbHeight = Math.min(Math.max(182 / list.scrollHeight, 0.1), 1) * 182;
				thumb.style.height = thumbHeight + 'px';
				updateThumb();
			}

			let scrolling = false;
			thumb.addEventListener('mousedown', () => void (scrolling = true));
			addEventListener('mouseup', () => void (scrolling = false));
			addEventListener('mousemove', e => {
				const deltaY = e.clientY - lastY;
				lastY = e.clientY;

				if (!scrolling) return;
				e.preventDefault();

				list.scrollTop = scrollTop =
					Math.min(Math.max(scrollTop + deltaY * list.scrollHeight / 182, 0), list.scrollHeight - 182);
				updateThumb();
			});

			let lastWasBarrier = true; // init to true, so we don't print a barrier as the first ever message (ugly)
			/**
			 * @param {string} authorName
			 * @param {[number, number, number, number]} rgb
			 * @param {string} text
			 * @param {boolean} server
			 */
			chat.add = (authorName, rgb, text, server) => {
				lastWasBarrier = false;

				const container = document.createElement('div');
				const author = document.createElement('span');
				author.style.cssText = `color: ${aux.rgba2hex(...rgb)}; padding-right: 0.75em;`;
				author.textContent = aux.trim(authorName);
				container.appendChild(author);

				const msg = document.createElement('span');
				if (server) msg.style.cssText = `color: ${aux.rgba2hex(...rgb)}`;
				msg.textContent = server ? text : aux.trim(text); // /help text can get cut off
				container.appendChild(msg);

				while (list.children.length > 100) list.firstChild?.remove();
				list.appendChild(container);
				scroll();
			};

			chat.barrier = () => {
				if (lastWasBarrier) return;
				lastWasBarrier = true;

				const barrier = document.createElement('div');
				barrier.style.cssText = 'width: calc(100% - 20px); height: 1px; background: #8888; margin: 10px;';
				list.appendChild(barrier);
				scroll();
			};

			chat.matchTheme = () => {
				list.style.color = aux.settings.darkTheme ? '#fffc' : '#000c';
				// make author names darker in light theme
				list.style.filter = aux.settings.darkTheme ? '' : 'brightness(75%)';

				toggle.style.backgroundColor = settings.theme[3] ? aux.rgba2hex6(...settings.theme) : '#e37955';
				thumb.style.backgroundColor = settings.theme[3] ? aux.rgba2hex6(...settings.theme) : '#fc7200';
			};
			chat.matchTheme();
			setInterval(() => chat.matchTheme(), 500);

			return chat;
		})();

		/** @param {string} msg */
		ui.error = msg => {
			const modal = /** @type {HTMLElement | null} */ (document.querySelector('#errormodal'));
			if (modal) modal.style.display = 'block';
			const desc = document.querySelector('#errormodal p');
			if (desc) desc.innerHTML = msg;
		};

		ui.captcha = (() => {
			const captcha = {};

			const modeBtns = /** @type {HTMLElement | null} */ (document.querySelector('.mode-btns'));
			/** @type {HTMLButtonElement} */
			const play = aux.require(document.querySelector('button#play-btn'),
				'Can\'t find the play button. Try reloading the page?');
			/** @type {HTMLButtonElement} */
			const spectate = aux.require(document.querySelector('button#spectate-btn'),
				'Can\'t find the spectate button. Try reloading the page?');

			let grecaptchaResolve;
			const grecaptcha = new Promise(r => grecaptchaResolve = r);
			let turnstileResolve;
			const turnstile = new Promise(r => turnstileResolve = r);
			let CAPTCHA2, CAPTCHA3, TURNSTILE;

			const empty = { execute: () => {}, ready: () => {}, render: () => {}, reset: () => {} };
			let readyCheck;
			readyCheck = setInterval(() => {
				// it's possible that recaptcha or turnstile may be removed in the future, so stay safe
				if (grecaptchaResolve) {
					let grecaptchaReal;
					({ grecaptcha: grecaptchaReal, CAPTCHA2, CAPTCHA3 } = /** @type {any} */ (window));
					if (grecaptchaReal?.ready && CAPTCHA2 && CAPTCHA3) {
						const resolve = grecaptchaResolve;
						grecaptchaResolve = undefined;

						grecaptchaReal.ready(() => {
							// prevent game.js from using grecaptcha and messing things up
							let { grecaptcha: grecaptchaNew } = /** @type {any} */ (window);
							/** @type {any} */ (window).grecaptcha = empty;
							resolve(grecaptchaNew);
						});
					}
				}

				if (turnstileResolve) {
					let turnstileReal;
					({ turnstile: turnstileReal, TURNSTILE } = /** @type {any} */ (window));
					if (turnstileReal?.ready && TURNSTILE) {
						const resolve = turnstileResolve;
						turnstileResolve = undefined;

						// turnstile.ready not needed; prevent game.js from using turnstile and messing things up
						/** @type {any} */ (window).turnstile = empty;
						resolve(turnstileReal);
					}
				}

				if (!grecaptchaResolve && !turnstileResolve) clearInterval(readyCheck);
			}, 50);

			/**
			 * @typedef {{
			 * 	cb: ((token: string) => void) | undefined,
			 * 	handle: any,
			 * 	mount: HTMLElement,
			 * 	reposition: () => boolean,
			 * 	type: string,
			 * }} CaptchaInstance
			 * @type {Map<symbol, CaptchaInstance>}
			 */
			const captchas = new Map();

			/** @param {symbol} view */
			captcha.remove = view => {
				const inst = captchas.get(view);
				if (!inst) return;

				if (inst.type === 'v2') grecaptcha.then(g => g.reset(inst.handle));
				// don't do anything for v3
				else if (inst.type === 'turnstile') turnstile.then(t => t.remove(inst.handle));

				inst.cb = () => {}; // ensure the token gets voided if solved
				inst.mount.remove();
				captchas.delete(view);
				captcha.reposition(); // ensure play/spectate buttons reappear
			};

			/**
			 * @param {symbol} view
			 * @param {string} type
			 * @param {(token: string) => void} cb
			 */
			captcha.request = (view, type, cb) => {
				const oldInst = captchas.get(view);
				if (oldInst?.type === type && oldInst.cb) {
					oldInst.cb = cb;
					return;
				}

				captcha.remove(view);

				const mount = document.createElement('div');
				document.body.appendChild(mount);
				const reposition = () => {
					if (view === world.viewId.spectate) {
						mount.style.cssText = 'position: fixed; bottom: 10px; left: 50vw; transform: translateX(-50%); \
							z-index: 1000;';
					} else if (view !== world.selected || ui.deathScreen.visible()) {
						mount.style.cssText = 'opacity: 0;'; // don't use display: none;
					} else if (escOverlayVisible && modeBtns) {
						const place = modeBtns?.getBoundingClientRect();
						mount.style.cssText = `position: fixed; top: ${place ? place.top + 'px' : '50vh'};
							left: ${place ? (place.left + place.width / 2) + 'px' : '50vw'};
							transform: translate(-50%, ${place ? '0%' : '-50%'}); z-index: 1000;`;
					} else {
						mount.style.cssText = `position: fixed; top: 50vh; left: 50vw; transform: translate(-50%, -50%);
							z-index: 1000;`;
					}

					// v3 is invisible, so it shouldn't hide the play buttons
					return type !== 'v3' && view !== world.viewId.spectate;
				};

				/** @type {CaptchaInstance} */
				const inst = { cb, handle: undefined, mount, reposition, type };
				captchas.set(view, inst);
				captcha.reposition();

				if (type === 'v2' || type === 'turnstile') {
					(type === 'v2' ? grecaptcha : turnstile).then(m => {
						inst.handle = m.render(mount, {
							callback: token => {
								inst.cb?.(token);
								inst.cb = undefined;
							},
							'error-callback': () => setTimeout(() => m.reset(inst.handle), 1000),
							'expired-callback': () => setTimeout(() => m.reset(inst.handle), 1000),
							sitekey: type === 'v2' ? CAPTCHA2 : TURNSTILE,
							theme: sigmod.exists ? 'dark' : 'light',
						});
					});
				} else if (type === 'v3') {
					grecaptcha.then(g => {
						g.execute(CAPTCHA3).then(token => {
							inst.cb?.(token);
							inst.cb = undefined;
						});
					});
				}
			};

			captcha.reposition = () => {
				let replacingModeButtons = false;
				for (const inst of captchas.values()) replacingModeButtons = inst.reposition() || replacingModeButtons;

				play.style.display = spectate.style.display = replacingModeButtons ? 'none' : '';
			};
			addEventListener('resize', () => captcha.reposition());

			return captcha;
		})();

		ui.linesplit = (() => {
			const linesplit = {};

			const overlay = document.createElement('div');
			overlay.style.cssText = `position: fixed; bottom: 10px; left: 50vw; transform: translateX(-50%);
				font: bold 24px Ubuntu; color: #fffc; z-index: 999;`;
			document.body.appendChild(overlay);

			linesplit.update = () => {
				const inputs = input.views.get(world.selected);
				if (!inputs?.lock) {
					overlay.style.display = 'none';
					return;
				}

				const fontFamily = `"${sigmod.settings.font || 'Ubuntu'}", Ubuntu`;
				if (overlay.style.fontFamily !== fontFamily) overlay.style.fontFamily = fontFamily;

				overlay.style.color = aux.settings.darkTheme ? '#fffc' : '#000c';
				if (inputs.lock.type === 'horizontal') {
					// left-right arrow svg
					overlay.innerHTML = `
						<svg viewBox="-6 0 36 24" style="width: 36px; height: 24px; vertical-align: bottom;">
    						<path stroke="currentColor" stroke-width="3" fill="none"
								d="M22,12 L2,12 M6,8 L2,12 L6,16 M18,8 L22,12 L18,16"></path>
    					</svg>(${sigmod.settings?.horizontalLineKey?.toUpperCase()})`;
					overlay.style.display = '';
				} else if (inputs.lock.type === 'vertical') {
					// up-down arrow svg
					overlay.innerHTML = `
						<svg viewBox="0 0 24 24" style="width: 24px; height: 24px; vertical-align: bottom;">
    						<path stroke="currentColor" stroke-width="3" fill="none"
								d="M12,22 L12,2 M8,6 L12,2 L16,6 M8,18 L12,22 L16,18"></path>
    					</svg>(${sigmod.settings?.verticalLineKey?.toUpperCase()})`;
					overlay.style.display = '';
				} else if (inputs.lock.type === 'fixed') {
					// left-right + up-down arrow svg
					overlay.innerHTML = `
						<svg viewBox="-6 0 36 24" style="width: 36px; height: 24px; vertical-align: bottom;">
    						<path stroke="currentColor" stroke-width="3" fill="none"
								d="M22,12 L2,12 M6,8 L2,12 L6,16 M18,8 L22,12 L18,16
								M12,22 L12,2 M8,6 L12,2 L16,6 M8,18 L12,22 L16,18"></path>
    					</svg>(${sigmod.settings?.fixedLineKey?.toUpperCase()})`;
					overlay.style.display = '';
				}
			};

			return linesplit;
		})();

		ui.perftab = (() => {
			const perftab = {};

			const overlay = document.createElement('div');
			overlay.style.cssText = 'position: fixed; top: 10px; left: 50vw; width: 0; height: 65px; \
				user-select: none; z-index: 2; background: #0006; transform: translateX(-50%); \
				display: none; grid-template-rows: 1fr; font: bold 14px Ubuntu;';
			document.body.appendChild(overlay);

			const tabs = perftab.tabs = new Map();
			const pollTab = (view, enabled) => {
				const oldTab = tabs.get(view);
				if (oldTab && !enabled) {
					oldTab.container.remove();
					tabs.delete(view);

					overlay.style.width = `${tabs.size * 100}px`;
					overlay.style.gridTemplateColumns = `repeat(${tabs.size}, 1fr)`;
					let i = 0;
					for (const otherTab of tabs.values()) otherTab.container.style.gridColumn = String(++i);
				}

				if (!oldTab && enabled) {
					const thisIndex = tabs.size + 1;
					overlay.style.width = `${(tabs.size + 1) * 100}px`;
					overlay.style.gridTemplateColumns = `repeat(${tabs.size + 1}, 1fr)`;

					const container = document.createElement('div');
					container.style.cssText = `grid-column: ${thisIndex}; width: 100px; position: relative;`;
					overlay.appendChild(container);

					let title = '?';
					if (view === world.viewId.primary) title = '#1';
					else if (view === world.viewId.secondary) title = '#2';
					else if (view === world.viewId.spectate) title = 'spec';

					const caption = document.createElement('div');
					caption.style.cssText = 'height: 20px; text-align: center; position: absolute; top: 0px; left: 0; width: 100px; color: #fff; line-height: 15px;';
					caption.textContent = title;
					container.appendChild(caption);

					const canvas = document.createElement('canvas');
					canvas.style.cssText = 'height: 40px; position: absolute; top: 20px; left: 10px; width: 80px;';
					container.appendChild(canvas);
					tabs.set(view, {
						container,
						title,
						caption,
						canvas,
						ctx: canvas.getContext('2d'),
						points: [],
						pointsIndex: 0,
						updated: performance.now(),
					});
				}
			};
			setInterval(() => {
				if (!settings.perftab) {
					overlay.style.display = 'none';
					return;
				}

				const fontFamily = `"${sigmod.settings.font || 'Ubuntu'}", Ubuntu`;
				if (overlay.style.fontFamily !== fontFamily) overlay.style.fontFamily = fontFamily;

				overlay.style.display = 'grid';
				pollTab(world.viewId.primary, true);
				pollTab(world.viewId.secondary, settings.multibox);
				pollTab(world.viewId.spectate, settings.spectator);
			}, 500);

			perftab.tick = view => {
				const tab = tabs.get(view);
				if (!tab) return;

				const now = performance.now();
				tab.points[tab.pointsIndex++ % 25] = [now - tab.updated, now];
				tab.updated = now;
				if (tab.pointsIndex % 25 === 0) {
					// update caption
					let maxDifference = 0;
					for (let i = 0; i < 25; ++i) {
						const diff = tab.points[i][0] - 40;
						if (maxDifference < diff) maxDifference = diff;
					}
					tab.caption.innerHTML = `${tab.title}:&nbsp;&nbsp;<span style="color: #fffc">±${Math.round(maxDifference)}ms</span>`;
				}

				const { canvas, ctx } = tab;
				canvas.width = Math.ceil(80 * (devicePixelRatio - 0.0001)); // clears the canvas
				canvas.height = Math.ceil(40 * (devicePixelRatio - 0.0001));

				ctx.strokeStyle = '#fff';
				ctx.lineWidth = 2 * devicePixelRatio;
				ctx.lineCap = 'round';
				for (let i = 0; i < 25; ++i) {
					if (!tab.points[i]) continue;
					const [delta, time] = tab.points[i];
					ctx.globalAlpha = (1 - (now - time) / 1000) * (delta < 40 ? 0.6 : 1);
					const x = (i + 1) / (25 + 1) * canvas.width;
					ctx.beginPath();
					ctx.moveTo(x, canvas.height/2);
					ctx.lineTo(x, (1 - delta / 80) * canvas.height);
					ctx.stroke();
				}
			};

			return perftab;
		})();

		const style = document.createElement('style');
		style.innerHTML = `
			/* make sure nothing gets cut off on the center menu panel */
			#menu-wrapper > .menu-center { height: fit-content !important; }
			/* hide the outline that sigmod puts on the minimap (i don't like it) */
			.minimap { border: none !important; box-shadow: none !important; }`;
		document.head.appendChild(style);

		return ui;
	})();

	///////////////////////////
	// Setup World Variables //
	///////////////////////////
	const CELL_TOPINDEX_MASK = 0xf; // 16 slots
	const CELL_BORN = 3 * 16;
	const CELL_DEADAT = 3 * 16 + 1;
	const CELL_DEADTO = 3 * 16 + 2;
	const CELL_TOPINDEX = 3 * 16 + 3;
	const CELL_RECORD_SIZE = 3 * 16 + 4;
	const PELLET_BORN = 0;
	const PELLET_DEADAT = 1;
	const PELLET_DEADTO = 2;
	const PELLET_RECORD_SIZE = 3;
	const TAB_PRIMARY = Symbol();
	const TAB_SECONDARY = Symbol();
	const TAB_SPECTATE = Symbol();
	/**
	 * @typedef {{
	 * 	id: number,
	 * 	vx: number, vy: number, vr: number, vjr: number, vupdated: number, vweight: number,
	 * 	tx: number, ty: number, tr: number,
	 * 	born: number, deadAt: number, deadTo: number,
	 * 	name: string, skin: string, clan: string,
	 * 	red: number, green: number, blue: number, jagged: boolean, sub: boolean,
	 * }} Cell
	 * @typedef {{
	 * 	id: number,
	 * 	tx: number, ty: number, tr: number,
	 * 	born: number, deadAt: number, deadTo: number,
	 * 	red: number, green: number, blue: number,
	 * }} Pellet
	 * @typedef {{
	 * 	border: { l: number, r: number, t: number, b: number } | undefined,
	 * 	camera: {
	 * 		x: number, tx: number,
	 * 		y: number, ty: number,
	 * 		scale: number, tscale: number,
	 * 		merged: boolean,
	 * 		updated: number,
	 * 	},
	 * 	leaderboard: { name: string, me: boolean, sub: boolean, place: number | undefined }[],
	 * 	owned: Set<number>,
	 * 	spawned: number,
	 * 	stats: object | undefined,
	 * 	used: number,
	 * 	view: Symbol,
	 * }} Vision
	 */
	const world = (() => {
		const world = {};

		/** @type {Map<number, Cell>} */
		world.cells = new Map();
		/** @type {Map<number, Pellet>} */
		world.pellets = new Map();
		world.viewId = { primary: TAB_PRIMARY, secondary: TAB_SECONDARY, spectate: TAB_SPECTATE };
		world.selected = world.viewId.primary;
		/** @type {Map<symbol, Vision>} */
		world.views = new Map();

		world.synchronized = true; // 2.8.0: strictly for backwards compatibility with SigMod

		world.alive = () => {
			for (const vision of world.views.values()) {
				for (const id of vision.owned) {
					const cell = world.cells.get(id);
					if (!cell || !cell[vision.view][CELL_DEADAT]) return true; // cells that don't exist yet are "alive"
				}
			}
			return false;
		};

		/**
		 * @typedef {{ mass: number, scale: number, sumX: number, sumY: number, weight: number }} SingleCamera
		 * @param {Vision} vision
		 * @param {number} weightExponent
		 * @param {number} now
		 * @returns {SingleCamera}
		 */
		world.singleCamera = (vision, weightExponent, now) => {
			let mass = 0, r = 0, sumX = 0, sumY = 0, weight = 0;
			for (const id of vision.owned) {
				const cell = world.cells.get(id);
				// don't include cells owned before respawning
				if (!cell || cell.born < vision.spawned) continue;

				if (settings.cameraMovement === 'instant') {
					const xyr = world.xyr(cell, undefined, now);
					r += xyr.r * xyr.weight; // note xyr.weight is correct regardless of `killer` param in world.xyr()
					mass += (xyr.r * xyr.r / 100) * xyr.weight;
					const cellWeight = xyr.weight * (xyr.r ** weightExponent);
					sumX += xyr.x * cellWeight;
					sumY += xyr.y * cellWeight;
					weight += cellWeight;
				} else { // settings.cameraMovement === 'default'
					if (cell.deadAt) continue;
					const xyr = world.xyr(cell, undefined, now);
					r += cell.tr;
					mass += cell.tr * cell.tr / 100;
					const cellWeight = cell.tr ** weightExponent;
					sumX += xyr.x * cellWeight;
					sumY += xyr.y * cellWeight;
					weight += cellWeight;
				}
			}

			return { mass, scale: Math.min(64 / r, 1) ** 0.4, sumX, sumY, weight };
		};

		/**
		 * @param {number} now
		 */
		world.cameras = now => {
			const weightExponent = settings.camera !== 'default' ? 2 : 0;

			// #1 : create disjoint sets of all cameras that are close together
			/** @type {Map<symbol, SingleCamera>}>} */
			const cameras = new Map();
			/** @type {Map<symbol, Set<symbol>>} */
			const sets = new Map();
			for (const [view, vision] of world.views) {
				cameras.set(view, world.singleCamera(vision, weightExponent, now));
				sets.set(view, new Set([view]));
			}

			// compute even if tabs won't actually be merged, because the multi outlines must still show
			if (settings.multibox) {
				for (const [view, vision] of world.views) {
					const set = /** @type {Set<symbol>} */ (sets.get(view));

					const camera = /** @type {SingleCamera} */ (cameras.get(view));
					if (camera.weight <= 0 || now - vision.used > 20_000) continue; // don't merge with inactive tabs
					const x = camera.sumX / camera.weight;
					const y = camera.sumY / camera.weight;
					const width = 1920 / 2 / camera.scale;
					const height = 1080 / 2 / camera.scale;

					for (const [otherView, otherVision] of world.views) {
						const otherSet = /** @type {Set<symbol>} */ (sets.get(otherView));
						if (set === otherSet || now - otherVision.used > 20_000) continue;

						const otherCamera = /** @type {SingleCamera} */ (cameras.get(otherView));
						if (otherCamera.weight <= 0) continue;
						const otherX = otherCamera.sumX / otherCamera.weight;
						const otherY = otherCamera.sumY / otherCamera.weight;
						const otherWidth = 1920 / 2 / otherCamera.scale;
						const otherHeight = 1080 / 2 / otherCamera.scale;

						// only merge with tabs if their vision regions are close. expand threshold depending on
						// how much mass each tab has (if both tabs are large, allow them to go pretty far)
						const threshold = 1000 + Math.min(camera.weight / 100 / 25, otherCamera.weight / 100 / 25);
						if (Math.abs(x - otherX) <= width + otherWidth + threshold
								&& Math.abs(y - otherY) <= height + otherHeight + threshold) {
							// merge disjoint sets
							for (const connectedView of otherSet) {
								set.add(connectedView);
								sets.set(connectedView, set);
							}
						}
					}
				}
			}

			// #2 : calculate and update merged camera positions
			/** @type {Set<Set<symbol>>} */
			const computed = new Set();
			for (const set of sets.values()) {
				if (computed.has(set)) continue;
				let mass = 0, sumX = 0, sumY = 0, weight = 0;
				if (settings.mergeCamera) {
					for (const view of set) {
						const camera = /** @type {SingleCamera} */ (cameras.get(view));
						mass += camera.mass;
						sumX += camera.sumX;
						sumY += camera.sumY;
						weight += camera.weight;
					}
				}

				for (const view of set) {
					const vision = /** @type {Vision} */ (world.views.get(view));
					if (!settings.mergeCamera) {
						({ mass, sumX, sumY, weight } = /** @type {SingleCamera} */ (cameras.get(view)));
					}

					let xyFactor;
					if (weight <= 0) {
						xyFactor = 20;
					} else if (settings.cameraMovement === 'instant') {
						xyFactor = 1;
					} else {
						// when spawning, move camera quickly (like vanilla), then make it smoother after a bit
						const aliveFor = (performance.now() - vision.spawned) / 1000;
						const a = Math.min(Math.max((aliveFor - 0.3) / 0.3, 0), 1);
						const base = settings.cameraSpawnAnimation ? 2 : 1;
						xyFactor = Math.min(settings.cameraSmoothness, base * (1-a) + settings.cameraSmoothness * a);
					}

					if (weight > 0) {
						vision.camera.tx = sumX / weight;
						vision.camera.ty = sumY / weight;
						let scale;
						if (settings.camera === 'default') scale = /** @type {SingleCamera} */ (cameras.get(view)).scale;
						else scale = Math.min(64 / Math.sqrt(100 * mass), 1) ** 0.4;
						vision.camera.tscale = settings.autoZoom ? scale : 0.25;
					}

					const dt = (now - vision.camera.updated) / 1000;
					vision.camera.x = aux.exponentialEase(vision.camera.x, vision.camera.tx, xyFactor, dt);
					vision.camera.y = aux.exponentialEase(vision.camera.y, vision.camera.ty, xyFactor, dt);
					vision.camera.scale
						= aux.exponentialEase(vision.camera.scale, input.zoom * vision.camera.tscale, 9, dt);
					vision.camera.merged = set.size > 1;
					vision.camera.updated = now;
				}

				computed.add(set);
			}
		};

		/** @param {symbol} view */
		world.create = view => {
			const old = world.views.get(view);
			if (old) return old;

			const vision = {
				border: undefined,
				camera: { x: 0, tx: 0, y: 0, ty: 0, scale: 0, tscale: 0, merged: false, updated: performance.now() - 1 },
				leaderboard: [],
				owned: new Set(),
				spawned: -Infinity,
				stats: undefined,
				used: -Infinity,
				view,
			};
			world.views.set(view, vision);
			return vision;
		};

		world.killCell = (view, cell, killerId, now) => {
			const record = cell[view];
			if (!record[CELL_BORN] || record[CELL_DEADAT]) return;
			record[CELL_DEADAT] = now;
			record[CELL_DEADTO] = killerId;

			if (killerId) {
				// immediately override death status
				cell.deadAt = now;
				cell.deadTo = killerId;
			} else {
				// only mark dead if all views see it dead
				const primaryDead = !cell[TAB_PRIMARY][CELL_BORN] || cell[TAB_PRIMARY][CELL_DEADAT];
				const secondaryDead = !cell[TAB_SECONDARY][CELL_BORN] || cell[TAB_SECONDARY][CELL_DEADAT];
				const spectateDead = !cell[TAB_SPECTATE][CELL_BORN] || cell[TAB_SPECTATE][CELL_DEADAT];
				if (primaryDead && secondaryDead && spectateDead) {
					cell.deadAt = now;
					cell.deadTo = 0;
				}
			}
		};

		world.killPellet = (view, pellet, killerId, now) => {
			// this is exactly the same as world.killCell, but using PELLET_* keys instead
			const record = pellet[view];
			if (!record[PELLET_BORN] || record[PELLET_DEADAT]) return;
			record[PELLET_DEADAT] = now;
			record[PELLET_DEADTO] = killerId;

			if (killerId) {
				// immediately override death status
				pellet.deadAt = now;
				pellet.deadTo = killerId;
			} else {
				// only mark dead if all views see it dead
				const primaryDead = !pellet[TAB_PRIMARY][PELLET_BORN] || pellet[TAB_PRIMARY][PELLET_DEADAT];
				const secondaryDead = !pellet[TAB_SECONDARY][PELLET_BORN] || pellet[TAB_SECONDARY][PELLET_DEADAT];
				const spectateDead = !pellet[TAB_SPECTATE][PELLET_BORN] || pellet[TAB_SPECTATE][PELLET_DEADAT];
				if (primaryDead && secondaryDead && spectateDead) {
					pellet.deadAt = now;
					pellet.deadTo = 0;
				}
			}
		};

		/** @param {symbol} view */
		world.score = view => {
			let score = 0;
			for (const id of (world.views.get(view)?.owned ?? [])) {
				const cell = world.cells.get(id);
				if (!cell) continue;
				const record = cell[view];
				if (record[CELL_DEADAT]) continue;
				const tr = record[3 * record[CELL_TOPINDEX] + 2];
				score += tr * tr / 100; // use exact score as given by the server, no interpolation
			}

			return score;
		};

		world.xyr = (cell, killer, now) => {
			let { tx, ty } = cell;
			if (killer) {
				// animate towards the killer's interpolated position (not their target position) for extra smoothness
				// we also assume the killer has not died (if it has, then weird stuff is OK to occur)
				const killerXyr = world.xyr(killer, undefined, now);
				({ x: tx, y: ty } = killerXyr);
			}

			let alpha = (now - cell.vupdated) / settings.drawDelay;
			alpha = alpha > 1 ? 1 : alpha;

			const x = cell.vx + (tx - cell.vx) * alpha;
			const y = cell.vy + (ty - cell.vy) * alpha;
			const r = cell.vr + (cell.tr - cell.vr) * alpha;
			const targetWeight = cell.deadAt ? 0 : 1;
			const weight = cell.vweight + (targetWeight - cell.vweight) * alpha;

			const dt = (now - cell.vupdated) / 1000;

			return {
				x, y, r, weight,
				jr: aux.exponentialEase(cell.vjr, r, settings.slowerJellyPhysics ? 10 : 5, dt),
			};
		};

		// clean up dead, invisible cells ONLY before uploading pellets
		let lastClean = performance.now();
		world.clean = () => {
			const now = performance.now();
			if (now - lastClean < 200) return;
			lastClean = now;

			const destroyAt = now - (settings.drawDelay + 200);
			for (const cell of world.cells.values()) {
				// now - cell.deadAt >= settings.drawDelay + 200
				// now - (settings.drawDelay + 200) - cell.deadAt >= 0
				// now - (settings.drawDelay + 200) >= cell.deadAt
				if (cell.deadAt && cell.deadAt <= destroyAt) world.cells.delete(cell.id);
			}
			for (const pellet of world.pellets.values()) {
				if (pellet.deadAt && pellet.deadAt <= destroyAt) world.pellets.delete(pellet.id);
			}
		};

		world.stats = { foodEaten: 0, highestPosition: 200, highestScore: 0, spawnedAt: undefined };

		return world;
	})();

	//////////////////////////
	// Setup All Networking //
	//////////////////////////
	const net = (() => {
		const net = {};

		// #1 : define state
		/** @type {Map<symbol, {
		 * 	handshake: { shuffle: Uint8Array, unshuffle: Uint8Array } | undefined,
		 * 	latency: number | undefined,
		 * 	pinged: number | undefined,
		 * 	playBlock: { state: 'leaving' | 'joining', started: number } | undefined,
		 * 	rejections: number,
		 * 	retries: number,
		 * 	ws: WebSocket | undefined,
		 * }>} */
		net.connections = new Map();

		/** @param {symbol} view */
		net.create = view => {
			if (net.connections.has(view)) return;

			net.connections.set(view, {
				handshake: undefined,
				latency: undefined,
				pinged: undefined,
				playBlock: undefined,
				rejections: 0,
				retries: 0,
				ws: connect(view),
				DEBUG_sent: 0,
				DEBUG_quest: 0,
			});
		};

		let captchaPostQueue = Promise.resolve();

		/**
		 * @param {symbol} view
		 * @param {(() => void)=} establishedCallback
		 * @returns {WebSocket | undefined}
		*/
		const connect = (view, establishedCallback) => {
			if (net.connections.get(view)?.ws) return; // already being handled by another process

			// do not allow sigmod's args[0].includes('sigmally.com') check to pass
			const realUrl = net.url();
			const fakeUrl = /** @type {any} */ ({ includes: () => false, toString: () => realUrl });
			let ws;
			try {
				ws = new WebSocket(fakeUrl);
			} catch (err) {
				console.error('can\'t make WebSocket:', err);
				aux.require(null, 'The server address is invalid. It probably has a typo.\n' +
					'- If using an insecure address (starting with "ws://" and not "wss://") that isn\'t localhost, ' +
					'enable Insecure Content in this site\'s browser settings.\n' +
					'- If using a local server, make sure to use localhost and not any other local IP.');
				return; // ts-check is dumb
			}
			if (net.connections.get(view)) net.connections.get(view).ws = ws;

			ws.binaryType = 'arraybuffer';
			ws.addEventListener('close', e => {
				console.error('WebSocket closed:', e);
				establishedCallback?.();
				establishedCallback = undefined;

				const connection = net.connections.get(view);
				const vision = world.views.get(view);
				if (!connection || !vision) return; // if the entry no longer exists, don't reconnect

				connection.handshake = undefined;
				connection.latency = undefined;
				connection.pinged = undefined;
				connection.playBlock = undefined;
				++connection.rejections;
				if (connection.retries > 0) --connection.retries;

				vision.border = undefined;
				// don't reset vision.camera
				vision.owned = new Set();
				vision.leaderboard = [];
				vision.spawned = -Infinity;
				vision.stats = undefined;

				const now = performance.now();
				for (const cell of world.cells.values()) world.killCell(view, cell, 0, now);
				for (const pellet of world.pellets.values()) world.killPellet(view, pellet, 0, now);
				render.uploadPellets = true;

				connection.ws = undefined;

				const thisUrl = net.url();
				const url = new URL(thisUrl); // use the current url, not realUrl
				const captchaEndpoint = `http${url.protocol === 'ws:' ? '' : 's'}://${url.host}/server/recaptcha/v3`;

				/** @param {string} type */
				const requestCaptcha = type => {
					ui.captcha.request(view, type, token => {
						captchaPostQueue = captchaPostQueue.then(() => new Promise(resolve => {
							aux.oldFetch(captchaEndpoint, {
								method: 'POST',
								headers: { 'content-type': 'application/json' },
								body: JSON.stringify({ token }),
							}).then(res => res.json()).then(res => res.status).catch(() => 'rejected')
								.then(status => {
									if (status === 'complete') connect(view, resolve);
									else setTimeout(() => connect(view, resolve), 1000);
								});
						}));
					});
				};

				if (connection.retries > 0) {
					setTimeout(() => connect(view), 500);
				} else {
					aux.oldFetch(captchaEndpoint).then(res => res.json()).then(res => res.version).catch(() => 'none')
						.then(type => {
							connection.retries = 3;
							if (type === 'v2' || type === 'v3' || type === 'turnstile') requestCaptcha(type);
							else setTimeout(() => connect(view), connection.rejections >= 5 ? 5000 : 500);
						});
				}
			});
			ws.addEventListener('error', () => {});
			ws.addEventListener('message', e => {
				const connection = net.connections.get(view);
				const vision = world.views.get(view);
				if (!connection || !vision) return ws.close();
				const dat = new DataView(e.data);

				if (!connection.handshake) {
					// skip version "SIG 0.0.1\0"
					let o = 10;

					const shuffle = new Uint8Array(256);
					const unshuffle = new Uint8Array(256);
					for (let i = 0; i < 256; ++i) {
						const shuffled = dat.getUint8(o + i);
						shuffle[i] = shuffled;
						unshuffle[shuffled] = i;
					}
					connection.handshake = { shuffle, unshuffle };

					if (world.alive()) net.play(world.selected, input.playData(input.name(view), false));
					return;
				}

				// do this so the packet can easily be sent to sigmod afterwards
				dat.setUint8(0, connection.handshake.unshuffle[dat.getUint8(0)]);

				const now = performance.now();
				let o = 1;
				switch (dat.getUint8(0)) {
					case 0x10: { // world update
						ui.perftab.tick(view); // do this first

						// (a) : eat
						const killCount = dat.getUint16(o, true);
						o += 2;
						for (let i = 0; i < killCount; ++i) {
							const killerId = dat.getUint32(o, true);
							const killedId = dat.getUint32(o + 4, true);
							o += 8;

							let isPellet = true;
							let killed = world.pellets.get(killedId) ?? (isPellet = false, world.cells.get(killedId));
							if (!killed) continue;

							if (isPellet) {
								killed.vupdated = now; // restart pellet movement
								world.killPellet(view, killed, killerId, now);
								if (vision.owned.has(killerId)) {
									++world.stats.foodEaten;
									if (!settings.disableQuests) net.food(view); // dumbass quest code go brrr
								}
							} else {
								const xyr = world.xyr(killed, undefined, now);
								killed.vx = xyr.x; killed.vy = xyr.y; killed.vr = xyr.r; killed.vjr = xyr.jr;
								killed.vweight = xyr.weight;
								killed.vupdated = now;
								world.killCell(view, killed, killerId, now);
							}
						}

						// (b) : add, upd
						let primaryHash, secondaryHash;
						for (const vision of world.views.values()) {
							for (const id of vision.owned) {
								const cell = world.cells.get(id);
								if (!cell || cell.deadAt) continue;
								if (vision.view === TAB_PRIMARY) primaryHash = cell.hash;
								else if (vision.view === TAB_SECONDARY) secondaryHash = cell.hash;
								else continue;
								break;
							}
						}
						const primaryVision = world.views.get(TAB_PRIMARY);
						const secondaryVision = world.views.get(TAB_SECONDARY); // maybe undefined

						while (true) {
							const id = dat.getUint32(o, true);
							o += 4;
							if (id === 0) break;

							const x = dat.getInt16(o, true);
							const y = dat.getInt16(o + 2, true);
							const r = dat.getUint16(o + 4, true);
							const flags = dat.getUint8(o + 6);
							const sub = !!dat.getUint8(o + 9);
							o += 10;

							let clan; [clan, o] = aux.readZTString(dat, o);

							let red = 127, green = 127, blue = 127;
							if (flags & 0x02) { // update color
								red = dat.getUint8(o++);
								green = dat.getUint8(o++);
								blue = dat.getUint8(o++);
							}

							/** @type {string | undefined} */
							let skin;
							if (flags & 0x04) { // update skin
								[skin, o] = aux.readZTString(dat, o);
								skin = aux.parseSkin(skin);
							}

							/** @type {string | undefined} */
							let name;
							if (flags & 0x08) { // update name
								[name, o] = aux.readZTString(dat, o);
								const parsed = aux.parseName(name);
								name = parsed[0];
								if (parsed[1]) skin = parsed[1];
							}

							const jagged = !!(flags & 0x11); // spiked or agitated
							const isEject = !!(flags & 0x20);
							const isPellet = r <= 40 && !isEject; // tourney servers have bigger pellets (r=40)

							if (isPellet) {
								let pellet = world.pellets.get(id);
								if (pellet) {
									// update
									if (flags & 0x02) {
										pellet.red = red / 255;
										pellet.green = green / 255;
										pellet.blue = blue / 255;
									}
									const record = pellet[view];
									// reset born, deadAt, deadTo if necessary
									if (!record[PELLET_BORN]) record[PELLET_BORN] = now; // mark local pellet as alive
									record[PELLET_DEADAT] = record[PELLET_DEADTO] = 0; //
									if (pellet.deadAt) { // mark merged pellet as alive again
										pellet.born = now;
										pellet.deadAt = pellet.deadTo = 0;
									}
								} else {
									// new
									world.pellets.set(id, pellet = {
										id,
										tx: x, ty: y, tr: r,
										vx: x, vy: y, vr: r, vweight: 0, vupdated: now,
										born: now, deadAt: 0, deadTo: 0,
										red: red / 255, green: green / 255, blue: blue / 255,
										[TAB_PRIMARY]: new Float32Array(PELLET_RECORD_SIZE),
										[TAB_SECONDARY]: new Float32Array(PELLET_RECORD_SIZE),
										[TAB_SPECTATE]: new Float32Array(PELLET_RECORD_SIZE),
									});
									pellet[view][PELLET_BORN] = now;
								}
							} else {
								let cell = world.cells.get(id);
								if (cell) {
									// update
									if (flags & 0x02) {
										cell.red = red / 255;
										cell.green = green / 255;
										cell.blue = blue / 255;
									}
									if (flags & 0x04) cell.name = name || 'An unnamed cell';
									if (flags & 0x08) cell.skin = skin ?? '';
									cell.clan = clan;
									cell.jagged = jagged;
									cell.sub = sub;

									const record = cell[view];
									if (!record[CELL_BORN]) record[CELL_BORN] = now;
									record[CELL_DEADAT] = record[CELL_DEADTO] = 0;
									if (cell.deadAt) {
										cell.born = now;
										cell.deadAt = cell.deadTo = 0;
										cell.vx = x; cell.vy = y; cell.vr = cell.vjr = r; cell.vweight = 0;
									}

									record[CELL_TOPINDEX] = (record[CELL_TOPINDEX] + 1) & CELL_TOPINDEX_MASK;
									record[3 * record[CELL_TOPINDEX]] = x;
									record[3 * record[CELL_TOPINDEX] + 1] = y;
									record[3 * record[CELL_TOPINDEX] + 2] = r;

									const { x: ix, y: iy, r: ir, jr, weight } = world.xyr(cell, undefined, now);
									cell.vx = ix;
									cell.vy = iy;
									cell.vr = ir;
									cell.vjr = jr;
									cell.vweight = weight;
									cell.vupdated = now;
									cell.tx = x;
									cell.ty = y;
									cell.tr = r;
								} else {
									// new
									const hash = String(red << 16 | green << 8 | blue) + name;
									world.cells.set(id, cell = {
										id,
										vx: x, vy: y, vr: r, vjr: r, vupdated: now, vweight: 0,
										tx: x, ty: y, tr: r,
										born: now, deadAt: 0, deadTo: 0,
										name: name || 'An unnamed cell', skin: skin ?? '', clan,
										red: red / 255, green: green / 255, blue: blue / 255,
										jagged, sub, hash,
										[TAB_PRIMARY]: new Float32Array(CELL_RECORD_SIZE),
										[TAB_SECONDARY]: new Float32Array(CELL_RECORD_SIZE),
										[TAB_SPECTATE]: new Float32Array(CELL_RECORD_SIZE),
										merged: { // 2.8.0: strictly for SigMod backwards compatibility
											get nx() { return cell.tx },
											get ny() { return cell.ty },
											get nr() { return cell.tr },
										},
									});

									const record = cell[view];
									record[CELL_BORN] = now;
									record[0] = x;
									record[1] = y;
									record[2] = r;
									record[CELL_TOPINDEX] = 0;

									// predict which cells we will own
									if (!isEject) {
										if (hash === primaryHash) primaryVision.owned.add(id);
										else if (hash === secondaryHash) secondaryVision.owned.add(id);
									}
								}
							}
						}

						// (c) : del
						const deleteCount = dat.getUint16(o, true);
						o += 2;
						for (let i = 0; i < deleteCount; ++i) {
							const deletedId = dat.getUint32(o, true);
							o += 4;

							let isPellet = true;
							const deleted = world.pellets.get(deletedId) ?? (isPellet = false, world.cells.get(deletedId));
							if (!deleted) continue;
							const record = deleted[view];
							if (isPellet) world.killPellet(view, deleted, 0, now);
							else world.killCell(view, deleted, 0, now);
						}

						// (d) : finalize, upload data
						world.clean();
						render.uploadPellets = true;

						// (e) : clear own cells that don't exist anymore (NOT on world.clean!)
						for (const id of vision.owned) {
							const cell = world.cells.get(id);
							if (!cell) {
								vision.owned.delete(id);
								continue;
							}
							const record = cell[view];
							if (record[CELL_BORN] && !record[CELL_DEADAT] && connection.playBlock?.state === 'joining') {
								connection.playBlock = undefined;
							}
						}

						ui.deathScreen.check();
						break;
					}

					case 0x11: { // update camera pos
						vision.camera.tx = dat.getFloat32(o, true);
						vision.camera.ty = dat.getFloat32(o + 4, true);
						vision.camera.tscale = dat.getFloat32(o + 8, true);
						break;
					}

					case 0x12: { // delete all cells
						// happens every time you respawn
						if (connection.playBlock?.state === 'leaving') connection.playBlock.state = 'joining';
						// the server won't respond to pings if you aren't in a world, and we don't want to show '????'
						// unless there's actually a problem
						connection.pinged = undefined;

						// DO NOT just clear the maps! when respawning, OgarII will not resend cell data if we spawn
						// nearby.

						for (const cell of world.cells.values()) world.killCell(view, cell, 0, now);
						for (const pellet of world.pellets.values()) world.killPellet(view, pellet, 0, now);
						render.uploadPellets = true;
						// passthrough
					}
					case 0x14: { // delete my cells
						// only reset spawn time if no other tab is alive.
						// this could be cheated (if you alternate respawning your tabs, for example) but i don't think
						// multiboxers ever see the stats menu anyway
						if (!world.alive()) world.stats.spawnedAt = undefined;
						ui.deathScreen.hide(); // don't trigger death screen on respawn
						break;
					}

					case 0x20: { // new owned cell
						// check if this is the first owned cell
						let first = true;
						let firstThis = true;
						for (const otherVision of world.views.values()) {
							for (const id of otherVision.owned) {
								const cell = world.cells.get(id);
								if (!cell) continue;
								const record = cell[otherVision.view];
								if (record[CELL_DEADAT]) continue;

								first = false;
								if (otherVision === vision) {
									firstThis = false;
									break;
								}
							}
						}
						if (first) world.stats.spawnedAt = now;
						if (firstThis) vision.spawned = now;

						vision.owned.add(dat.getUint32(o, true));
						break;
					}

					case 0x31: { // ffa leaderboard list
						const lb = [];
						const count = dat.getUint32(o, true);
						o += 4;

						/** @type {number | undefined} */
						let myPosition;
						for (let i = 0; i < count; ++i) {
							const me = !!dat.getUint32(o, true);
							o += 4;

							let name; [name, o] = aux.readZTString(dat, o);
							[name] = aux.parseName(name);

							// why this is copied into every leaderboard entry is beyond my understanding
							myPosition = dat.getUint32(o, true);
							const sub = !!dat.getUint32(o + 4, true);
							o += 8;

							lb.push({ name, sub, me, place: undefined });
						}

						if (myPosition) { // myPosition could be zero
							if (myPosition - 1 >= lb.length) {
								const nick = input.nick[view === world.viewId.secondary ? 1 : 0].value;
								lb.push({
									me: true,
									name: aux.parseName(nick)[0],
									place: myPosition,
									sub: false, // doesn't matter
								});
							}

							world.stats.highestPosition = Math.min(world.stats.highestPosition, myPosition);
						}

						vision.leaderboard = lb;
						break;
					}

					case 0x40: { // border update
						vision.border = {
							l: dat.getFloat64(o, true),
							t: dat.getFloat64(o + 8, true),
							r: dat.getFloat64(o + 16, true),
							b: dat.getFloat64(o + 24, true),
						};
						break;
					}

					case 0x63: { // chat message
						// only handle non-server chat messages on the primary tab, to prevent duplicate messages
						const flags = dat.getUint8(o++);
						const server = flags & 0x80;
						if (view !== world.viewId.primary && !server) return; // skip sigmod processing too
						const rgb = /** @type {[number, number, number, number]} */
							([dat.getUint8(o++) / 255, dat.getUint8(o++) / 255, dat.getUint8(o++) / 255, 1]);

						let name; [name, o] = aux.readZTString(dat, o);
						let msg; [msg, o] = aux.readZTString(dat, o);
						ui.chat.add(name, rgb, msg, !!(flags & 0x80));
						break;
					}

					case 0xb4: { // incorrect password alert
						ui.error('Password is incorrect');
						break;
					}

					case 0xfe: { // server stats (in response to a ping)
						let statString; [statString, o] = aux.readZTString(dat, o);
						vision.stats = JSON.parse(statString);
						if (connection.pinged !== undefined) connection.latency = now - connection.pinged;
						connection.pinged = undefined;
						break;
					}
				}

				sigmod.handleMessage?.(dat);
			});
			ws.addEventListener('open', () => {
				establishedCallback?.();
				establishedCallback = undefined;

				const connection = net.connections.get(view);
				const vision = world.views.get(view);
				if (!connection || !vision) return ws.close();

				ui.captcha.remove(view);

				connection.rejections = 0;
				connection.retries = 0;

				vision.camera.x = vision.camera.tx = 0;
				vision.camera.y = vision.camera.ty = 0;
				vision.camera.scale = input.zoom;
				vision.camera.tscale = 1;
				ws.send(aux.textEncoder.encode('SIG 0.0.1\x00'));
			});

			return ws;
		};

		// ping loop
		setInterval(() => {
			for (const connection of net.connections.values()) {
				if (!connection.handshake || connection.ws?.readyState !== WebSocket.OPEN) continue;
				if (connection.pinged !== undefined) connection.latency = -1; // display '????ms'
				connection.pinged = performance.now();
				connection.ws.send(connection.handshake.shuffle.slice(0xfe, 0xfe + 1));
			}
		}, 2000);

		// #2 : define helper functions
		/** @type {HTMLSelectElement | null} */
		const gamemode = document.querySelector('#gamemode');
		/** @type {HTMLOptionElement | null} */
		const firstGamemode = document.querySelector('#gamemode option');
		net.url = () => {
			if (location.search.startsWith('?ip=')) return location.search.slice('?ip='.length);
			else return 'wss://' + (gamemode?.value || firstGamemode?.value || 'ca0.sigmally.com/ws/');
		};

		/** @param {symbol} view */
		net.respawnable = view => {
			const vision = world.views.get(view);
			const con = net.connections.get(view);
			if (!vision || !con?.ws) return false;

			// only allow respawns on localhost (players on personal private servers can simply append `localhost`)
			return world.score(view) < 5500 || con.ws.url.includes('localhost');
		};

		// disconnect if a different gamemode is selected
		// an interval is preferred because the game can apply its gamemode setting *after* connecting without
		// triggering any events
		setInterval(() => {
			for (const connection of net.connections.values()) {
				if (!connection.ws) continue;
				if (connection.ws.readyState !== WebSocket.CONNECTING && connection.ws.readyState !== WebSocket.OPEN)
					continue;
				if (connection.ws.url === net.url()) continue;
				connection.ws.close();
			}
		}, 200);

		/**
		 * @param {symbol} view
		 * @param {number} opcode
		 * @param {object} data
		 */
		const sendJson = (view, opcode, data) => {
			// must check readyState as a weboscket might be in the 'CLOSING' state (so annoying!)
			const connection = net.connections.get(view);
			if (!connection?.handshake || connection.ws?.readyState !== WebSocket.OPEN) return;
			const dataBuf = aux.textEncoder.encode(JSON.stringify(data));
			const dat = new DataView(new ArrayBuffer(dataBuf.byteLength + 2));

			dat.setUint8(0, connection.handshake.shuffle[opcode]);
			for (let i = 0; i < dataBuf.byteLength; ++i) dat.setUint8(1 + i, dataBuf[i]);
			connection.ws.send(dat);
			++connection.DEBUG_sent;
		};

		// #5 : export input functions
		/**
		 * @param {symbol} view
		 * @param {number} x
		 * @param {number} y
		 */
		net.move = (view, x, y) => {
			const connection = net.connections.get(view);
			if (!connection?.handshake || connection.ws?.readyState !== WebSocket.OPEN) return;
			const dat = new DataView(new ArrayBuffer(13));

			dat.setUint8(0, connection.handshake.shuffle[0x10]);
			dat.setInt32(1, x, true);
			dat.setInt32(5, y, true);
			connection.ws.send(dat);
			++connection.DEBUG_sent;
		};

		/** @param {number} opcode */
		const bindOpcode = opcode => /** @param {symbol} view */ view => {
			const connection = net.connections.get(view);
			if (!connection?.handshake || connection.ws?.readyState !== WebSocket.OPEN) return;
			connection.ws.send(connection.handshake.shuffle.slice(opcode, opcode + 1));
			++connection.DEBUG_sent;
			if (opcode === 0xc0 || opcode === 0xbf) ++connection.DEBUG_quest;
		};
		net.w = bindOpcode(21);
		net.qdown = bindOpcode(18);
		net.qup = bindOpcode(19);
		net.split = bindOpcode(17);
		// quests
		net.food = bindOpcode(0xc0);
		net.time = bindOpcode(0xbf);

		// reversed argument order for sigmod compatibility
		/**
		 * @param {string} msg
		 * @param {symbol=} view
		 */
		net.chat = (msg, view = world.selected) => {
			const connection = net.connections.get(view);
			if (!connection?.handshake || connection.ws?.readyState !== WebSocket.OPEN) return;

			if (msg.toLowerCase().startsWith('/leaveworld') && !net.respawnable(view)) return; // prevent abuse

			const msgBuf = aux.textEncoder.encode(msg);
			const dat = new DataView(new ArrayBuffer(msgBuf.byteLength + 3));

			dat.setUint8(0, connection.handshake.shuffle[0x63]);
			// flags = 0
			for (let i = 0; i < msgBuf.byteLength; ++i) dat.setUint8(2 + i, msgBuf[i]);
			connection.ws.send(dat);
			++connection.DEBUG_sent;
		};

		/**
		 * @param {symbol} view
		 * @param {{ name: string, skin: string, [x: string]: any }} data
		 */
		net.play = (view, data) => {
			const connection = net.connections.get(view);
			const now = performance.now();
			if (!data.state) {
				if (!connection || (connection.playBlock !== undefined && now - connection.playBlock.started < 750) || world.score(view) > 0) return;
				connection.playBlock = { state: 'joining', started: now };
				ui.deathScreen.hide();
			}
			sendJson(view, 0x00, data);
		};

		/**
		 * @param {symbol} view
		 * @param {{ name: string, skin: string, [x: string]: any }} data
		 */
		net.respawn = (view, data) => {
			const connection = net.connections.get(view);
			const now = performance.now();
			if (!connection?.handshake || connection.ws?.readyState !== WebSocket.OPEN) return;
			if (connection.playBlock !== undefined && now - connection.playBlock.started < 750) return;

			const score = world.score(view);
			if (score <= 0) { // if dead, no need to leave+rejoin the world
				net.play(view, data);
				return;
			} else if (!net.respawnable(view)) return;

			if (settings.blockNearbyRespawns) {
				const vision = world.views.get(view);
				if (!vision?.border) return;

				world.cameras(now);
				const { l, r, t, b } = vision.border;

				for (const otherVision of world.views.values()) {
					if (otherVision === vision || world.score(otherVision.view) <= 0) continue;

					// block respawns if both views are close enough (minimap squares give too large of a threshold)
					const d = Math.hypot(vision.camera.tx - otherVision.camera.tx, vision.camera.ty - otherVision.camera.ty);
					if (d <= Math.min(r - l, b - t) / 4) return;
				}
			}

			connection.playBlock = { state: 'leaving', started: now };
			net.chat('/leaveworld', view); // immediately remove from world, which removes all player cells
			sendJson(view, 0x00, data); // enqueue into matchmaker (/joinworld is not available if dead)
			setTimeout(() => { // wait until Matchmaker.update() puts us into a world
			 	sendJson(view, 0x00, data); // spawn
			}, 60); // = 40ms (1 tick) + 20ms (margin of error)
		};

		// create initial connection
		world.create(world.viewId.primary);
		net.create(world.viewId.primary);
		let lastChangedSpectate = -Infinity;
		setInterval(() => {
			if (!settings.multibox) {
				world.selected = world.viewId.primary;
				ui.captcha.reposition();
				ui.linesplit.update();
			}

			const now = performance.now();
			if (settings.spectator) {
				const vision = world.create(world.viewId.spectate);
				net.create(world.viewId.spectate);
				net.play(world.viewId.spectate, { name: '', skin: '', clan: aux.userData?.clan, state: 2 });

				// only press Q to toggle once in a while, in case ping is above 200
				if (now - lastChangedSpectate > 1000) {
					if (vision.camera.tscale > 0.39) { // when roaming, the spectate scale is set to ~0.4
						net.qdown(world.viewId.spectate);
						lastChangedSpectate = now;
					}
				} else {
					net.qup(world.viewId.spectate); // doubly serves as anti-afk
				}
			} else {
				const con = net.connections.get(world.viewId.spectate);
				if (con?.ws && con?.ws.readyState !== WebSocket.CLOSED && con?.ws.readyState !== WebSocket.CLOSING) {
					con?.ws.close();
				}
				net.connections.delete(world.viewId.spectate);
				world.views.delete(world.viewId.spectate);
				input.views.delete(world.viewId.spectate);

				for (const cell of world.cells.values()) world.killCell(world.viewId.spectate, cell, 0, now);
				for (const pellet of world.pellets.values()) world.killPellet(world.viewId.spectate, pellet, 0, now);
			}
		}, 200);

		// dumbass quest code go brrr
		setInterval(() => {
			if (settings.disableQuests) return;
			for (const view of net.connections.keys()) net.time(view);
		}, 1000);

		return net;
	})();

	//////////////////////////
	// Setup Input Handlers //
	//////////////////////////
	const input = (() => {
		const input = {};

		// #1 : general inputs
		// between -1 and 1
		/** @type {[number, number]} */
		input.current = [0, 0];
		/** @type {Map<symbol, {
		 * 		forceW: boolean,
		 * 		lock: { type: 'point', mouse: [number, number], world: [number, number], until: number }
		 * 			| { type: 'horizontal', world: [number, number], lastSplit: number }
		 * 			| { type: 'vertical', world: [number, number], lastSplit: number }
		 * 			| { type: 'fixed' }
		 * 			| undefined,
		 * 		mouse: [number, number], // between -1 and 1
		 * 		w: boolean,
		 * 		world: [number, number], // world position; only updates when tab is selected
		 * }>} */
		input.views = new Map();
		input.zoom = 1;

		/** @param {symbol} view */
		const create = view => {
			const old = input.views.get(view);
			if (old) return old;

			/** @type {typeof input.views extends Map<symbol, infer T> ? T : never} */
			const inputs = { forceW: false, lock: undefined, mouse: [0, 0], w: false, world: [0, 0] };
			input.views.set(view, inputs);
			return inputs;
		};

		/**
		 * @param {symbol} view
		 * @param {[number, number]} x, y
		 * @returns {[number, number]}
		 */
		input.toWorld = (view, [x, y]) => {
			const camera = world.views.get(view)?.camera;
			if (!camera) return [0, 0];
			return [
				camera.x + x * (innerWidth / innerHeight) * 540 / camera.scale,
				camera.y + y * 540 / camera.scale,
			];
		};

		// sigmod freezes the player by overlaying an invisible div, so we just listen for canvas movements instead
		addEventListener('mousemove', e => {
			if (ui.escOverlayVisible()) return;
			// sigmod freezes the player by overlaying an invisible div, so we respect it
			if (e.target instanceof HTMLDivElement
				&& /** @type {CSSUnitValue | undefined} */ (e.target.attributeStyleMap.get('z-index'))?.value === 99)
				return;
			input.current = [(e.clientX / innerWidth * 2) - 1, (e.clientY / innerHeight * 2) - 1];
		});

		const unfocused = () => ui.escOverlayVisible() || document.activeElement?.tagName === 'INPUT';

		/** @param {symbol} view */
		input.name = view => view === world.viewId.secondary ? input.nick[1].value : input.nick[0].value;

		/**
		 * @param {symbol} view
		 * @param {boolean} forceUpdate
		 */
		input.move = (view, forceUpdate) => {
			const now = performance.now();
			const inputs = input.views.get(view) ?? create(view);
			if (view === world.selected) inputs.mouse = input.current;

			const worldMouse = input.toWorld(view, inputs.mouse);

			switch (inputs.lock?.type) {
				case 'point':
					if (now > inputs.lock.until) break;
					const d = Math.hypot(inputs.mouse[0] - inputs.lock.mouse[0], inputs.mouse[1] - inputs.lock.mouse[1]);
					// only lock the mouse as long as the mouse has not moved further than 25% (of 2) of the screen away
					if (d < 0.5 || Number.isNaN(d)) {
						net.move(view, ...inputs.lock.world);
						return;
					}
					break;
				
				case 'horizontal':
					if (settings.moveAfterLinesplit && inputs.lock.lastSplit !== -Infinity) {
						// move horizontally only after splitting to maximize distance travelled
						if (Math.abs(inputs.mouse[0]) <= 0.2) {
							net.move(view, worldMouse[0], inputs.lock.world[1]);
						} else {
							net.move(view, (2 ** 31 - 1) * (inputs.mouse[0] >= 0 ? 1 : -1), inputs.lock.world[1]);
						}
					} else {
						net.move(view, ...inputs.lock.world);
					}
					return;

				case 'vertical':
					if (settings.moveAfterLinesplit ? inputs.lock.lastSplit !== -Infinity : now - inputs.lock.lastSplit <= 150) {
						// vertical linesplits require a bit of upwards movement to split upwards
						if (Math.abs(inputs.mouse[1]) <= 0.2) {
							net.move(view, inputs.lock.world[0], worldMouse[1]);
						} else {
							net.move(view, inputs.lock.world[0], (2 ** 31 - 1) * (inputs.mouse[1] >= 0 ? 1 : -1));
						}
					} else {
						net.move(view, ...inputs.lock.world);
					}
					return;

				case 'fixed':
					// rotate around the tab's camera center (otherwise, spinning around on a tab feels unnatural)
					const vision = world.views.get(view);
					const worldCenter = world.singleCamera(vision, settings.camera !== 'default' ? 2 : 0, now);
					const x = worldMouse[0] - worldCenter.sumX / worldCenter.weight;
					const y = worldMouse[1] - worldCenter.sumY / worldCenter.weight;
					// create two points along the 2^31 integer boundary (OgarII uses ~~x and ~~y to truncate positions
					// to 32-bit integers), choose which one is closer to zero (the one actually within the boundary)
					const max = 2 ** 31 - 1;
					const xClamp = /** @type {const} */ ([max * Math.sign(x), y / x * max * Math.sign(x)]);
					const yClamp = /** @type {const} */ ([x / y * max * Math.sign(y), max * Math.sign(y)]);
					if (Math.hypot(...xClamp) < Math.hypot(...yClamp)) net.move(view, ...xClamp);
					else net.move(view, ...yClamp);
					return;
			}

			inputs.lock = undefined;
			if (world.selected === view || forceUpdate) inputs.world = worldMouse;
			net.move(view, ...inputs.world);
		};

		/**
		 * @param {symbol} view
		 * @param {number=} count
		 */
		input.split = (view, count = 1) => {
			const inputs = create(view);
			if (inputs?.lock?.type === 'vertical' || inputs?.lock?.type === 'horizontal') {
				inputs.lock.lastSplit = performance.now();
			}
			input.move(view, true);
			for (let i = 0; i < count; ++i) net.split(view);
		};

		/** @param {symbol} view */
		input.autoRespawn = view => {
			if (!world.alive()) return;
			net.play(world.selected, input.playData(input.name(view), false));
		};

		/**
		 * @param {symbol} view
		 */
		input.tab = view => {
			if (view === world.selected) return;
			const oldView = world.selected;
			const inputs = create(oldView);
			const newInputs = create(view);

			newInputs.w = inputs.w;
			inputs.w = false; // stop current tab from feeding; don't change forceW
			// update mouse immediately (after setTimeout, when mouse events happen)
			setTimeout(() => inputs.world = input.toWorld(oldView, inputs.mouse = input.current));

			world.selected = view;
			world.create(world.selected);
			net.create(world.selected);

			ui.captcha.reposition();
			ui.linesplit.update();
		};

		setInterval(() => {
			create(world.selected);
			for (const [view, inputs] of input.views) {
				input.move(view, false);
				// if tapping W very fast, make sure at least one W is ejected
				if (inputs.forceW || inputs.w) net.w(view);
				inputs.forceW = false;
			}
		}, 40);

		/** @type {Node | null} */
		let sigmodChat;
		setInterval(() => sigmodChat ||= document.querySelector('.modChat'), 500);
		addEventListener('wheel', e => {
			if (unfocused()) return;
			// when scrolling through sigmod chat, don't allow zooming.
			// for consistency, use the container .modChat and not #mod-messages as #mod-messages can have zero height
			if (sigmodChat && sigmodChat.contains(/** @type {Node} */ (e.target))) return;
			// support for the very obscure "scroll by page" setting in windows
			// i don't think browsers support DOM_DELTA_LINE, so assume DOM_DELTA_PIXEL otherwise
			const deltaY = e.deltaMode === e.DOM_DELTA_PAGE ? e.deltaY : e.deltaY / 100;
			input.zoom *= 0.8 ** (deltaY * settings.scrollFactor);
			const minZoom = (!settings.multibox && !aux.settings.zoomout) ? 1 : 0.8 ** 15;
			input.zoom = Math.min(Math.max(input.zoom, minZoom), 0.8 ** -21);
		});

		/**
		 * @param {KeyboardEvent | MouseEvent} e
		 * @returns {boolean}
		 */
		const handleKeybind = e => {
			const keybind = aux.keybind(e)?.toLowerCase();
			if (!keybind) return false;

			const release = e.type === 'keyup' || e.type === 'mouseup';
			if (!release && settings.multibox && keybind === settings.multibox.toLowerCase()) {
				e.preventDefault(); // prevent selecting anything on the page

				const newTab = world.selected === world.viewId.primary ? world.viewId.secondary : world.viewId.primary
				input.tab(newTab);
				input.autoRespawn(newTab);
				return true;
			}

			return false;
		};

		addEventListener('keydown', e => {
			const view = world.selected;
			const inputs = input.views.get(view) ?? create(view);

			// never allow pressing Tab by itself
			if (e.code === 'Tab' && !e.ctrlKey && !e.altKey && !e.metaKey) e.preventDefault();

			if (e.code === 'Escape') {
				if (document.activeElement === ui.chat.input) ui.chat.input.blur();
				else ui.toggleEscOverlay();
				return;
			}

			if (unfocused()) {
				if (e.code === 'Enter' && document.activeElement === ui.chat.input && ui.chat.input.value.length > 0) {
					net.chat(ui.chat.input.value.slice(0, 15), world.selected);
					ui.chat.input.value = '';
					ui.chat.input.blur();
				}

				return;
			}

			if (handleKeybind(e)) return;

			if (settings.blockBrowserKeybinds) {
				if (e.code === 'F11') {
					// force true fullscreen to make sure Ctrl+W and other binds are caught.
					// not well supported on safari
					if (!document.fullscreenElement) {
						document.body.requestFullscreen?.()?.catch(() => {});
						/** @type {any} */ (navigator).keyboard?.lock()?.catch(() => {});
					} else {
						document.exitFullscreen?.()?.catch(() => {});
						/** @type {any} */ (navigator).keyboard?.unlock()?.catch(() => {});
					}
				}

				if (e.code !== 'Tab') e.preventDefault(); // allow ctrl+tab and alt+tab
			} else if (e.ctrlKey && e.code === 'KeyW') {
				e.preventDefault(); // doesn't seem to work for me, but works for others
			}

			// if fast feed is rebound, only allow the spoofed W's from sigmod
			let fastFeeding = e.code === 'KeyW';
			if (sigmod.settings.rapidFeedKey && sigmod.settings.rapidFeedKey !== 'w') {
				fastFeeding &&= !e.isTrusted;
			}
			if (fastFeeding) inputs.forceW = inputs.w = true;

			switch (e.code) {
				case 'KeyQ':
					if (!e.repeat) net.qdown(world.selected);
					break;
				case 'Space': {
					if (!e.repeat) {
						// send mouse position immediately, so the split will go in the correct direction.
						// setTimeout is used to ensure that our mouse position is actually updated (it comes after
						// keydown events)
						setTimeout(() => input.split(view));
					}
					break;
				}
				case 'Enter': {
					ui.chat.input.focus();
					break;
				}
			}

			const vision = world.views.get(view);
			if (!vision) return;

			// use e.isTrusted in case the key was bound to W
			if (e.isTrusted && !e.repeat) {
				if (e.key.toLowerCase() === sigmod.settings.doubleKey?.toLowerCase()) {
					setTimeout(() => input.split(view));
					// separate both splits by 50ms (at least one tick, 40ms) to ensure the correct piece goes in front
					// only when pushsplitting
					setTimeout(() => input.split(view), (vision.owned.size > 4 && settings.delayDouble) ? 50 : 0);
				} else if (e.key.toLowerCase() === sigmod.settings.tripleKey?.toLowerCase()) {
					// don't override any locks, and don't update 'until'
					inputs.lock ||= {
						type: 'point',
						mouse: inputs.mouse,
						world: input.toWorld(world.selected, inputs.mouse),
						until: performance.now() + 650,
					};
					setTimeout(() => input.split(view, 3));
				} else if (e.key.toLowerCase() === sigmod.settings.quadKey?.toLowerCase()) {
					setTimeout(() => input.split(view, 4));
				}
			}
			const camera = world.singleCamera(vision, 0, Infinity); // use latest data (.nx, .ny), uninterpolated

			if (e.isTrusted && e.key.toLowerCase() === sigmod.settings.horizontalLineKey?.toLowerCase()) {
				if (inputs.lock?.type === 'horizontal') {
					inputs.lock = undefined;
					ui.linesplit.update();
					return;
				}

				inputs.lock = {
					type: 'horizontal',
					world: [camera.sumX / camera.weight, camera.sumY / camera.weight],
					lastSplit: -Infinity,
				};
				ui.linesplit.update();
				return;
			}

			if (e.isTrusted && e.key.toLowerCase() === sigmod.settings.verticalLineKey?.toLowerCase()) {
				if (inputs.lock?.type === 'vertical') {
					inputs.lock = undefined;
					ui.linesplit.update();
					return;
				}

				inputs.lock = {
					type: 'vertical',
					world: [camera.sumX / camera.weight, camera.sumY / camera.weight],
					lastSplit: -Infinity,
				};
				ui.linesplit.update();
				return;
			}

			if (e.isTrusted && e.key.toLowerCase() === sigmod.settings.fixedLineKey?.toLowerCase()) {
				if (inputs.lock?.type === 'fixed') inputs.lock = undefined;
				else inputs.lock = { type: 'fixed' };
				ui.linesplit.update();
				return;
			}

			if (e.isTrusted && e.key.toLowerCase() === sigmod.settings.respawnKey?.toLowerCase()) {
				net.respawn(view, input.playData(input.name(view), false));
				return;
			}
		});

		addEventListener('keyup', e => {
			// allow inputs if unfocused
			if (e.code === 'KeyQ') net.qup(world.selected);
			else if (e.code === 'KeyW') {
				const inputs = input.views.get(world.selected) ?? create(world.selected);
				inputs.w = false; // don't change forceW
			}

			if (handleKeybind(e)) return;
		});

		addEventListener('mousedown', e => void (!unfocused() && handleKeybind(e)));
		addEventListener('mouseup', e => void (!unfocused() && handleKeybind(e)));
		addEventListener('beforeunload', e => e.preventDefault()); // prompt before closing window
		ui.game.canvas.addEventListener('contextmenu', e => e.preventDefault()); // prevent right clicking on the game
		// prevent dragging when some things are selected - i have a habit of unconsciously clicking all the time,
		// making me regularly drag text, disabling my mouse inputs for a bit
		addEventListener('dragstart', e => e.preventDefault());

		// #2 : play and spectate buttons, and captcha
		/**
		 * @param {string} name
		 * @param {boolean} spectating
		 */
		input.playData = (name, spectating) => {
			/** @type {HTMLInputElement | null} */
			const password = document.querySelector('input#password');

			return {
				state: spectating ? 2 : undefined,
				name,
				skin: aux.userData ? aux.settings.skin : '',
				token: aux.userData?.token,
				sub: (aux.userData?.subscription ?? 0) > Date.now(),
				clan: aux.userData?.clan,
				showClanmates: aux.settings.showClanmates,
				password: password?.value,
				email: aux.userData?.email,
			};
		};

		/** @type {HTMLInputElement[]} */
		input.nick = [aux.require(document.querySelector('input#nick'),
			'Can\'t find the nickname element. Try reloading the page?')];
		const nick2 = input.nick[1] = input.nick[0].cloneNode(true);
		nick2.maxLength = 50;
		nick2.placeholder = 'Nickname #2';
		nick2.value = settings.multiNames[0];
		nick2.addEventListener('change', () => {
			settings.multiNames[0] = nick2.value;
			settings.save();
		});

		const nick2Row = input.nick[0].parentElement.cloneNode();
		nick2Row.appendChild(nick2);
		nick2Row.style.display = settings.multibox ? '' : 'none';
		setInterval(() => nick2Row.style.display = settings.multibox ? '' : 'none', 500);
		input.nick[0].parentElement.insertAdjacentElement('afterend', nick2Row);

		/** @type {HTMLButtonElement} */
		const play = aux.require(document.querySelector('button#play-btn'),
			'Can\'t find the play button. Try reloading the page?');
		/** @type {HTMLButtonElement} */
		const spectate = aux.require(document.querySelector('button#spectate-btn'),
			'Can\'t find the spectate button. Try reloading the page?');

		const submitPlay = (spectate) => {
			const con = net.connections.get(world.selected);
			if (!con?.handshake) return;
			ui.toggleEscOverlay(false);
			net.play(world.selected, input.playData(input.name(world.selected), spectate));
		};
		play.addEventListener('click', () => submitPlay(false));
		spectate.addEventListener('click', () => submitPlay(true));

		play.disabled = spectate.disabled = true;
		setInterval(() => {
			play.disabled = spectate.disabled = !net.connections.get(world.selected)?.handshake;
		}, 100);

		return input;
	})();

	//////////////////////////
	// Configure WebGL Data //
	//////////////////////////
	const glconf = (() => {
		// note: WebGL functions only really return null if the context is lost - in which case, data will be replaced
		// anyway after it's restored. so, we cast everything to a non-null type.
		const glconf = {};
		const programs = glconf.programs = {};
		const uniforms = glconf.uniforms = {};
		/** @type {WebGLBuffer} */
		glconf.pelletAlphaBuffer = /** @type {never} */ (undefined);
		/** @type {WebGLBuffer} */
		glconf.pelletBuffer = /** @type {never} */ (undefined);
		glconf.vao = {};

		const gl = ui.game.gl;
		/** @type {Map<string, number>} */
		const uboBindings = new Map();

		const shader = (name, type, source) => {
			const s = /** @type {WebGLShader} */ (gl.createShader(type));
			gl.shaderSource(s, source);
			gl.compileShader(s);

			// note: compilation errors should not happen in production
			aux.require(
				gl.getShaderParameter(s, gl.COMPILE_STATUS) || gl.isContextLost(),
				`Can\'t compile WebGL2 shader "${name}". You might be on a weird browser.\n\nFull error log:\n` +
				gl.getShaderInfoLog(s),
			);

			return s;
		}

		const program = (name, vSource, fSource, ubos, textures) => {
			const vShader = shader(`${name}.vShader`, gl.VERTEX_SHADER, vSource.trim());
			const fShader = shader(`${name}.fShader`, gl.FRAGMENT_SHADER, fSource.trim());
			const p = /** @type {WebGLProgram} */ (gl.createProgram());

			gl.attachShader(p, vShader);
			gl.attachShader(p, fShader);
			gl.linkProgram(p);

			// note: linking errors should not happen in production
			aux.require(
				gl.getProgramParameter(p, gl.LINK_STATUS) || gl.isContextLost(),
				`Can\'t link WebGL2 program "${name}". You might be on a weird browser.\n\nFull error log:\n` +
				gl.getProgramInfoLog(p),
			);

			for (const tag of ubos) {
				const index = gl.getUniformBlockIndex(p, tag); // returns 4294967295 if invalid... just don't make typos
				let binding = uboBindings.get(tag);
				if (binding === undefined) uboBindings.set(tag, binding = uboBindings.size);
				gl.uniformBlockBinding(p, index, binding);

				const size = gl.getActiveUniformBlockParameter(p, index, gl.UNIFORM_BLOCK_DATA_SIZE);
				const ubo = uniforms[tag] = gl.createBuffer();
				gl.bindBuffer(gl.UNIFORM_BUFFER, ubo);
				gl.bufferData(gl.UNIFORM_BUFFER, size, gl.DYNAMIC_DRAW);
				gl.bindBufferBase(gl.UNIFORM_BUFFER, binding, ubo);
			}

			// bind texture uniforms to TEXTURE0, TEXTURE1, etc.
			gl.useProgram(p);
			if (textures.length > 0) gl.uniform1iv(gl.getUniformLocation(p, 'u_textures'), textures);
			gl.useProgram(null);

			return p;
		}

		const parts = {
			boilerplate: '#version 300 es\nprecision highp float; precision highp int;',
			borderUbo: `layout(std140) uniform Border {
				vec4 u_border_color; // @ 0x00, i = 0
				vec4 u_border_xyzw_lrtb; // @ 0x10, i = 4
				int u_border_flags; // @ 0x20, i = 8
				float u_background_width; // @ 0x24, i = 9
				float u_background_height; // @ 0x28, i = 10
				float u_border_time; // @ 0x2c, i = 11
			};`,
			cameraUbo: `layout(std140) uniform Camera {
				float u_camera_ratio; // @ 0x00
				float u_camera_scale; // @ 0x04
				vec2 u_camera_pos; // @ 0x08
			};`,
			circleUbo: `layout(std140) uniform Circle {
				float u_circle_alpha; // @ 0x00
				float u_circle_scale; // @ 0x04
			};`,
			playerUbo: `layout(std140) uniform Player {
				vec4 u_cell_active_outline; // @ 0x00
				vec4 u_cell_inactive_outline; // @ 0x10
				vec4 u_cell_unsplittable_outline; // @ 0x20
				vec4 u_cell_subtle_outline_override; // @ 0x30
				vec4 u_cell_name_color1; // @ 0x40
				vec4 u_cell_name_color2; // @ 0x50
				float u_cell_active_outline_thickness; // @ 0x60
			};`,
			tracerUbo: `layout(std140) uniform Tracer {
				vec4 u_tracer_color; // @ 0x00, i = 0
				float u_tracer_thickness; // @ 0x10, i = 4
			};`,
		};

		glconf.init = () => {
			gl.enable(gl.BLEND);
			gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

			// create programs and uniforms
			programs.bg = program('bg', `
				${parts.boilerplate}
				layout(location = 0) in vec2 a_vertex;
				${parts.borderUbo}
				${parts.cameraUbo}
				flat out float f_blur;
				flat out float f_thickness;
				out vec2 v_uv;
				out vec2 v_world_pos;

				void main() {
					f_blur = 1.0 * (540.0 * u_camera_scale);
					f_thickness = max(3.0 / f_blur, 25.0); // force border to always be visible, otherwise it flickers

					v_world_pos = a_vertex * vec2(u_camera_ratio, 1.0) / u_camera_scale;
					v_world_pos += u_camera_pos * vec2(1.0, -1.0);

					if ((u_border_flags & 0x04) != 0) { // background repeating
						v_uv = v_world_pos * 0.02 * (50.0 / u_background_width);
						v_uv /= vec2(1.0, u_background_height / u_background_width);
					} else {
						v_uv = (v_world_pos - vec2(u_border_xyzw_lrtb.x, u_border_xyzw_lrtb.z))
							/ vec2(u_border_xyzw_lrtb.y - u_border_xyzw_lrtb.x,
								u_border_xyzw_lrtb.w - u_border_xyzw_lrtb.z);
						v_uv = vec2(v_uv.x, 1.0 - v_uv.y); // flip vertically
					}

					gl_Position = vec4(a_vertex, 0, 1); // span the whole screen
				}
			`, `
				${parts.boilerplate}
				flat in float f_blur;
				flat in float f_thickness;
				in vec2 v_uv;
				in vec2 v_world_pos;
				${parts.borderUbo}
				${parts.cameraUbo}
				uniform sampler2D u_textures;
				out vec4 out_color;

				void main() {
					if ((u_border_flags & 0x01) != 0) { // background enabled
						if ((u_border_flags & 0x04) != 0 // repeating
							|| (0.0 <= min(v_uv.x, v_uv.y) && max(v_uv.x, v_uv.y) <= 1.0)) { // within border
							out_color = texture(u_textures, v_uv);
						}
					}

					// make a larger inner rectangle and a normal inverted outer rectangle
					float inner_alpha = min(
						min((v_world_pos.x + f_thickness) - u_border_xyzw_lrtb.x,
							u_border_xyzw_lrtb.y - (v_world_pos.x - f_thickness)),
						min((v_world_pos.y + f_thickness) - u_border_xyzw_lrtb.z,
							u_border_xyzw_lrtb.w - (v_world_pos.y - f_thickness))
					);
					float outer_alpha = max(
						max(u_border_xyzw_lrtb.x - v_world_pos.x, v_world_pos.x - u_border_xyzw_lrtb.y),
						max(u_border_xyzw_lrtb.z - v_world_pos.y, v_world_pos.y - u_border_xyzw_lrtb.w)
					);
					float alpha = clamp(f_blur * min(inner_alpha, outer_alpha), 0.0, 1.0);
					if (u_border_color.a == 0.0) alpha = 0.0;

					vec4 border_color;
					if ((u_border_flags & 0x08) != 0) { // rainbow border
						float angle = atan(v_world_pos.y, v_world_pos.x) + u_border_time;
						float red = (2.0/3.0) * cos(6.0 * angle) + 1.0/3.0;
						float green = (2.0/3.0) * cos(6.0 * angle - 2.0 * 3.1415926535 / 3.0) + 1.0/3.0;
						float blue = (2.0/3.0) * cos(6.0 * angle - 4.0 * 3.1415926535 / 3.0) + 1.0/3.0;
						border_color = vec4(red, green, blue, 1.0);
					} else {
						border_color = u_border_color;
					}

					out_color = out_color * (1.0 - alpha) + border_color * alpha;
				}
			`, ['Border', 'Camera'], [0]);

			// also used to draw glow
			programs.circle = program('circle', `
				${parts.boilerplate}
				layout(location = 0) in vec2 a_vertex;
				layout(location = 1) in vec2 a_cell_pos;
				layout(location = 2) in float a_cell_radius;
				layout(location = 3) in vec4 a_cell_color;
				layout(location = 4) in float a_cell_alpha;
				${parts.cameraUbo}
				${parts.circleUbo}
				out vec2 v_vertex;
				flat out float f_blur;
				flat out vec4 f_cell_color;

				void main() {
					float radius = a_cell_radius;
					f_cell_color = a_cell_color * vec4(1, 1, 1, a_cell_alpha * u_circle_alpha);
					if (u_circle_scale > 0.0) {
						f_blur = 1.0;
						radius *= u_circle_scale;
					} else {
						f_blur = 0.5 * a_cell_radius * (540.0 * u_camera_scale);
					}
					v_vertex = a_vertex;

					vec2 clip_pos = -u_camera_pos + a_cell_pos + v_vertex * radius;
					clip_pos *= u_camera_scale * vec2(1.0 / u_camera_ratio, -1.0);
					gl_Position = vec4(clip_pos, 0, 1);
				}
			`, `
				${parts.boilerplate}
				in vec2 v_vertex;
				flat in float f_blur;
				flat in vec4 f_cell_color;
				out vec4 out_color;

				void main() {
					// use squared distance for more natural glow; shouldn't matter for pellets
					float d = length(v_vertex.xy);
					out_color = f_cell_color;
					out_color.a *= clamp(f_blur * (1.0 - d), 0.0, 1.0);
				}
			`, ['Camera', 'Circle'], []);

			programs.player = program('player', `
				${parts.boilerplate}
				layout(location = 0) in vec2 a_vertex;
				layout(location = 1) in vec2 a_pos;
				layout(location = 2) in vec2 a_size;
				layout(location = 3) in vec2 a_texture_pos;
				layout(location = 4) in vec2 a_texture_size;
				layout(location = 5) in float a_texture_index;
				layout(location = 6) in vec2 a_silhouette_pos;
				layout(location = 7) in float a_silhouette_index;
				layout(location = 8) in vec4 a_color;
				layout(location = 9) in float a_flags;
				${parts.cameraUbo}
				${parts.playerUbo}
				flat out vec4 f_active_outline;
				flat out float f_active_radius;
				flat out float f_blur;
				flat out int f_flags;
				flat out int f_silhouette_index;
				flat out vec4 f_subtle_outline;
				flat out float f_subtle_radius;
				flat out int f_texture_index;
				flat out vec4 f_unsplittable_outline;
				flat out float f_unsplittable_radius;
				out vec4 v_color;
				out vec2 v_silhouette_uv;
				out vec2 v_vertex;
				out vec2 v_uv;

				void main() {
					f_blur = 0.5 * a_size.x * (540.0 * u_camera_scale);
					f_silhouette_index = int(a_silhouette_index);
					f_texture_index = int(a_texture_index);
					f_flags = int(a_flags);

					vec2 vertex01 = a_vertex * 0.5 + 0.5;
					v_color = a_color;
					v_uv = (a_texture_pos + vertex01 * a_texture_size) / vec2(4096, 4096);
					v_silhouette_uv = (a_silhouette_pos + vertex01 * a_texture_size) / vec2(4096, 4096);
					v_vertex = a_vertex;

					// gold name
					float gradient_a = ((f_flags & 0x200) != 0) ? 0.5 : (length(vertex01) / sqrt(2.0));
					if ((f_flags & 0x100) != 0) {
						// #eb9500 * 1.2 to #f9bf0d * 1.2
						v_color.rgb = vec3(1.1058, 0.7011, 0) * (1.0 - gradient_a)
							+ vec3(1.1717, 0.8988, 0.0611) * gradient_a;
					}

					// custom name gradient
					if ((f_flags & 0x80) != 0) {
						v_color.rgb = u_cell_name_color1.rgb * (1.0 - gradient_a) + u_cell_name_color2.rgb * gradient_a;
					}

					// subtle outlines (at least 1px wide)
					float subtle_thickness = max(max(a_size.x * 0.02, 2.0 / (540.0 * u_camera_scale)), 10.0);
					f_subtle_radius = 1.0 - (subtle_thickness / a_size.x);
					if ((f_flags & 0x02) != 0) {
						f_subtle_outline = a_color * 0.9; // darker outline by default
						f_subtle_outline.rgb += (u_cell_subtle_outline_override.rgb - f_subtle_outline.rgb)
							* u_cell_subtle_outline_override.a;
					} else {
						f_subtle_outline = vec4(0, 0, 0, 0);
					}

					// unsplittable cell outline, 2x the subtle thickness
					// (except at small sizes, it shouldn't look overly thick)
					float unsplittable_thickness = max(max(a_size.x * 0.04, 4.0 / (540.0 * u_camera_scale)), 10.0);
					f_unsplittable_radius = 1.0 - (unsplittable_thickness / a_size.x);
					if ((f_flags & 0x10) != 0) {
						f_unsplittable_outline = u_cell_unsplittable_outline;
					} else {
						f_unsplittable_outline = vec4(0, 0, 0, 0);
					}

					// active multibox outlines (thick, a % of the visible cell radius)
					// or at minimum, 3x the subtle thickness
					float active_thickness = max(max(a_size.x * 0.06, 6.0 / (540.0 * u_camera_scale)), 10.0);
					f_active_radius = 1.0 - max(active_thickness / a_size.x, u_cell_active_outline_thickness);
					if ((f_flags & 0x0c) != 0) {
						f_active_outline = (f_flags & 0x04) != 0 ? u_cell_active_outline : u_cell_inactive_outline;
					} else {
						f_active_outline = vec4(0, 0, 0, 0);
					}

					vec2 clip_pos = -u_camera_pos + a_pos + a_vertex * a_size;
					clip_pos *= u_camera_scale * vec2(1.0 / u_camera_ratio, -1.0);
					gl_Position = vec4(clip_pos, 0, 1);
				}
			`, `
				${parts.boilerplate}
				flat in vec4 f_active_outline;
				flat in float f_active_radius;
				flat in float f_blur;
				flat in int f_flags;
				flat in int f_silhouette_index;
				flat in vec4 f_subtle_outline;
				flat in float f_subtle_radius;
				flat in int f_texture_index;
				flat in vec4 f_unsplittable_outline;
				flat in float f_unsplittable_radius;
				in vec4 v_color;
				in vec2 v_silhouette_uv;
				in vec2 v_vertex;
				in vec2 v_uv;
				${parts.playerUbo}
				uniform sampler2D u_textures[8];
				out vec4 out_color;

				float sharpf(float x) {
					// a cubic function with turning points at (0,0) and (1,0)
					// meant to sharpen out blurry linear interpolation
					return x * x * (3.0 - 2.0*x);
				}

				vec4 sharp(vec4 v) {
					return vec4(sharpf(v.x), sharpf(v.y), sharpf(v.z), sharpf(v.w));
				}

				vec4 from_texture_array(int index, vec2 uv) {
					if (index == -1) return vec4(0, 0, 0, 0);
					else if (index == 0) return texture(u_textures[0], uv);
					else if (index == 1) return texture(u_textures[1], uv);
					else if (index == 2) return texture(u_textures[2], uv);
					else if (index == 3) return texture(u_textures[3], uv);
					else if (index == 4) return texture(u_textures[4], uv);
					else if (index == 5) return texture(u_textures[5], uv);
					else if (index == 6) return texture(u_textures[6], uv);
					else if (index == 7) return texture(u_textures[7], uv);
				}

				void main() {
					vec4 texcol = from_texture_array(f_texture_index, v_uv);

					if ((f_flags & 0x01) != 0) {
						// decorate like a cell
						float d = length(v_vertex);

						// color under skin
						if ((f_flags & 0x20) != 0) {
							out_color = vec4(v_color.rgb * (1.0 - texcol.a) + texcol.rgb, 1); // texture on top of color
						} else {
							out_color = texcol; // no color under skin
						}

						// subtle outline
						if ((f_flags & 0x02) != 0) {
							float a = clamp(f_blur * (d - f_subtle_radius), 0.0, 1.0) * f_subtle_outline.a;
							out_color.rgb += (f_subtle_outline.rgb - out_color.rgb) * a;
						}

						// active multibox outline
						if ((f_flags & 0x0c) != 0) {
							float a = clamp(f_blur * (d - f_active_radius), 0.0, 1.0) * f_active_outline.a;
							out_color.rgb += (f_active_outline.rgb - out_color.rgb) * a;
						}

						// unsplittable cell outline
						if ((f_flags & 0x10) != 0) {
							float a = clamp(f_blur * (d - f_unsplittable_radius), 0.0, 1.0) * f_unsplittable_outline.a;
							out_color.rgb += (f_unsplittable_outline.rgb - out_color.rgb) * a;
						}

						// circle mask
						float a = clamp(-f_blur * (d - 1.0), 0.0, 1.0);
						out_color.a *= a * v_color.a;
					} else {
						// decorate like text
						if (f_silhouette_index != -1) {
							vec4 silcol = from_texture_array(f_silhouette_index, v_silhouette_uv);
							// #fff - #000 => color (text)
							// #fff - #fff => #fff (respect emoji)
							// #888 - #888 => #888 (respect emoji)
							// #fff - #888 => #888 + color/2 (blur/antialias)
							out_color = silcol * vec4(1, 1, 1, v_color.a) + sharp(texcol - silcol) * v_color;
						} else {
							out_color = sharp(texcol) * v_color;
						}
					}
				}
			`, ['Camera', 'Player'], [0, 1, 2, 3, 4, 5, 6, 7]);

			programs.tracer = program('tracer', `
				${parts.boilerplate}
				layout(location = 0) in vec2 a_vertex;
				layout(location = 1) in vec2 a_pos1;
				layout(location = 2) in vec2 a_pos2;
				${parts.cameraUbo}
				${parts.tracerUbo}
				out vec2 v_vertex;

				void main() {
					v_vertex = a_vertex;
					float alpha = (a_vertex.x + 1.0) / 2.0;
					float d = length(a_pos2 - a_pos1);
					float thickness = 0.001 / u_camera_scale * u_tracer_thickness;
					// black magic
					vec2 world_pos = a_pos1 + (a_pos2 - a_pos1)
						* mat2(alpha, a_vertex.y / d * thickness, a_vertex.y / d * -thickness, alpha);

					vec2 clip_pos = -u_camera_pos + world_pos;
					clip_pos *= u_camera_scale * vec2(1.0 / u_camera_ratio, -1.0);
					gl_Position = vec4(clip_pos, 0, 1);
				}
			`, `
				${parts.boilerplate}
				in vec2 v_pos;
				${parts.tracerUbo}
				out vec4 out_color;

				void main() {
					out_color = u_tracer_color;
				}
			`, ['Camera', 'Tracer'], []);

			const attribute = (buffer, location, divisor, pointerArgs) => {
				gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
				gl.enableVertexAttribArray(location);
				gl.vertexAttribPointer(location, ...pointerArgs);
				gl.vertexAttribDivisor(location, divisor);
			};

			// everything is drawn as a square, then resized with other attributes
			const squareBuffer = () => {
				const squareBuffer = gl.createBuffer();
				gl.bindBuffer(gl.ARRAY_BUFFER, squareBuffer);
				gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ -1, -1,   1, -1,   -1, 1,   1, 1 ]), gl.STATIC_DRAW);
				return squareBuffer;
			}

			// background vao
			gl.bindVertexArray(glconf.backgroundVao = gl.createVertexArray());
			attribute(squareBuffer(), 0, 0, [2, gl.FLOAT, false, 0, 0]); // vec2 a_vertex

			// circle vao (for cell glow only)
			glconf.circleCellBuffer = gl.createBuffer();
			glconf.circleCellAlphaBuffer = gl.createBuffer();
			gl.bindVertexArray(glconf.circleCellVao = gl.createVertexArray());
			attribute(squareBuffer(), 0, 0, [2, gl.FLOAT, false, 0, 0]); // vec2 a_vertex
			attribute(glconf.circleCellBuffer, 1, 1, [2, gl.FLOAT, false, 4 * 7, 0]); // vec2 a_cell_pos
			attribute(glconf.circleCellBuffer, 2, 1, [1, gl.FLOAT, false, 4 * 7, 4 * 2]); // float a_cell_radius
			attribute(glconf.circleCellBuffer, 3, 1, [4, gl.FLOAT, false, 4 * 7, 4 * 3]); // vec4 a_cell_color
			attribute(glconf.circleCellAlphaBuffer, 4, 1, [1, gl.FLOAT, false, 0, 0]); // float a_cell_alpha

			// circle vao (for pellets and pellet glow)
			glconf.circlePelletBuffer = gl.createBuffer();
			glconf.circlePelletAlphaBuffer = gl.createBuffer();
			gl.bindVertexArray(glconf.circlePelletVao = gl.createVertexArray());
			attribute(squareBuffer(), 0, 0, [2, gl.FLOAT, false, 0, 0]); // vec2 a_vertex
			attribute(glconf.circlePelletBuffer, 1, 1, [2, gl.FLOAT, false, 4 * 7, 0]); // vec2 a_cell_pos
			attribute(glconf.circlePelletBuffer, 2, 1, [1, gl.FLOAT, false, 4 * 7, 4 * 2]); // float a_cell_radius
			attribute(glconf.circlePelletBuffer, 3, 1, [4, gl.FLOAT, false, 4 * 7, 4 * 3]); // vec4 a_cell_color
			attribute(glconf.circlePelletAlphaBuffer, 4, 1, [1, gl.FLOAT, false, 0, 0]); // float a_cell_alpha

			// player vao (for cells and text)
			/* layout(location = 0) in vec2 a_vertex;
				layout(location = 1) in vec2 a_pos;
				layout(location = 2) in vec2 a_size;
				layout(location = 3) in vec2 a_texture_pos;
				layout(location = 4) in vec2 a_texture_size;
				layout(location = 5) in vec4 a_color;
				layout(location = 6) in int a_flags; */
			glconf.playerBuffer = gl.createBuffer();
			gl.bindVertexArray(glconf.playerVao = gl.createVertexArray());
			attribute(squareBuffer(), 0, 0, [2, gl.FLOAT, false, 0, 0]); // vec2 a_vertex
			attribute(glconf.playerBuffer, 1, 1, [2, gl.FLOAT, false, 4 * 17, 0]); // vec2 a_pos
			attribute(glconf.playerBuffer, 2, 1, [2, gl.FLOAT, false, 4 * 17, 4 * 2]); // vec2 a_size
			attribute(glconf.playerBuffer, 3, 1, [2, gl.FLOAT, false, 4 * 17, 4 * 4]); // vec2 a_texture_pos
			attribute(glconf.playerBuffer, 4, 1, [2, gl.FLOAT, false, 4 * 17, 4 * 6]); // vec2 a_texture_size
			attribute(glconf.playerBuffer, 5, 1, [1, gl.FLOAT, false, 4 * 17, 4 * 8]); // float a_texture_index
			attribute(glconf.playerBuffer, 6, 1, [2, gl.FLOAT, false, 4 * 17, 4 * 9]); // vec2 a_silhouette_pos
			attribute(glconf.playerBuffer, 7, 1, [1, gl.FLOAT, false, 4 * 17, 4 * 11]); // float a_silhouette_index
			attribute(glconf.playerBuffer, 8, 1, [4, gl.FLOAT, false, 4 * 17, 4 * 12]); // vec4 a_color
			attribute(glconf.playerBuffer, 9, 1, [1, gl.FLOAT, false, 4 * 17, 4 * 16]); // int a_flags

			// tracer vao
			glconf.tracerBuffer = gl.createBuffer();
			gl.bindVertexArray(glconf.tracerVao = gl.createVertexArray());
			attribute(squareBuffer(), 0, 0, [2, gl.FLOAT, false, 0, 0]); // vec2 a_vertex
			attribute(glconf.tracerBuffer, 1, 1, [2, gl.FLOAT, false, 4 * 4, 0]); // vec2 a_pos1
			attribute(glconf.tracerBuffer, 2, 1, [2, gl.FLOAT, false, 4 * 4, 4 * 2]); // vec2 a_pos2
		};

		glconf.init();
		return glconf;
	})();

	///////////////////////////////
	// Define Rendering Routines //
	///////////////////////////////
	const render = (() => {
		const render = {};
		const { gl } = ui.game;

		const ATLAS_GRID = 128;
		const ATLAS_PER_ROW = 4096 / ATLAS_GRID;
		const BASE_TEXT_SIZE = Math.floor(ATLAS_GRID / 3) * 2;

		// no point in breaking this across multiple lines
		// eslint-disable-next-line max-len
		const darkGridSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAGBJREFUaIHtz4EJwCAAwDA39oT/H+qeEAzSXNA+a61xgfmeLtilEU0jmkY0jWga0TSiaUTTiKYRTSOaRjSNaBrRNKJpRNOIphFNI5pGNI1oGtE0omlEc83IN8aYpyN2+AH6nwOVa0odrQAAAABJRU5ErkJggg==';
		// eslint-disable-next-line max-len
		const lightGridSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAGFJREFUaIHtzwENgDAQwMA9LvAvdJgg2UF6CtrZe6+vm5n7Oh3xlkY0jWga0TSiaUTTiKYRTSOaRjSNaBrRNKJpRNOIphFNI5pGNI1oGtE0omlE04imEc1vRmatdZ+OeMMDa8cDlf3ZAHkAAAAASUVORK5CYII=';

		let now = performance.now(); // used everywhere in render module
		let lastMinimapDraw = now;
		/** @type {{ bg: ImageData, darkTheme: boolean } | undefined} */
		let minimapCache;
		document.fonts.addEventListener('loadingdone', () => void (minimapCache = undefined));

		render.atlasImages = [];
		render.atlas = new Map();
		render.colors = new Map();
		render.addToAtlas = (key, imageData, width, height, type, padding, isBackground) => {
			if (width > 4096 || height > 4096 || isBackground) {
				// has to be its own texture (backgrounds are their own texture so they can repeat easily, or if not
				// then they are likely high quality?)
				const texture = gl.createTexture();
				gl.bindTexture(gl.TEXTURE_2D, texture);
				gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imageData);
				gl.generateMipmap(gl.TEXTURE_2D);
				const rectangle = 1n << BigInt(ATLAS_PER_ROW * ATLAS_PER_ROW) - 1n;
				const atlas = {
					texture,
					reservedMap: undefined,
					removed: 0,
				};
				render.atlasImages.push(atlas);

				const entry = {
					key, atlasIndex: render.atlasImages.length - 1,
					x: 0, y: 0, w: width, h: height,
					type, rectangle: undefined, padding, accessed: performance.now(),
				};
				render.atlas.set(key, entry);
				return entry;
			}

			const upWidth = Math.ceil(width / ATLAS_GRID) * ATLAS_GRID;
			const upHeight = Math.ceil(height / ATLAS_GRID) * ATLAS_GRID;
			let rectangle = 1n;
			for (let x = 1; x * ATLAS_GRID < upWidth; ++x) rectangle |= rectangle << 1n;
			for (let y = 1; y * ATLAS_GRID < upHeight; ++y) rectangle |= rectangle << BigInt(ATLAS_PER_ROW);

			for (let i = 0; i < render.atlasImages.length; ++i) {
				const atlas = render.atlasImages[i];
				if (!atlas || atlas.reservedMap === undefined) continue; // this is/was a large image slot
				for (let x = 0; x * ATLAS_GRID + upWidth <= 4096; ++x) {
					for (let y = 0; y * ATLAS_GRID + upHeight <= 4096; ++y) { // this is not as slow as you'd think
						const shiftedRectangle = rectangle << BigInt(y * ATLAS_PER_ROW + x);
						if (atlas.reservedMap & shiftedRectangle) continue; // reserved

						atlas.reservedMap |= shiftedRectangle;
						gl.bindTexture(gl.TEXTURE_2D, atlas.texture);
						gl.texSubImage2D(gl.TEXTURE_2D, 0, x * ATLAS_GRID, y * ATLAS_GRID, width, height, gl.RGBA, gl.UNSIGNED_BYTE, imageData);
						gl.generateMipmap(gl.TEXTURE_2D);
						const entry = {
							key, atlasIndex: i, x: x * ATLAS_GRID, y: y * ATLAS_GRID, w: width, h: height,
							type, rectangle: shiftedRectangle, padding,
						};
						render.atlas.set(key, entry);
						return entry;
					}
				}
			}

			// no desirable atlas image was found
			let bestIndex;
			for (let i = 0; bestIndex === undefined; ++i) {
				if (!render.atlasImages[i]) bestIndex = i;
			}

			const texture = gl.createTexture();
			gl.bindTexture(gl.TEXTURE_2D, texture);
			gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 4096, 4096);
			const atlas = {
				texture,
				reservedMap: rectangle,
				removed: 0,
			};
			render.atlasImages[bestIndex] = atlas;

			gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, imageData);
			gl.generateMipmap(gl.TEXTURE_2D); // generating mipmaps for the entire image is wasteful but faster(?)
			const entry = {
				key, atlasIndex: bestIndex,
				x: 0, y: 0, w: width, h: height,
				type, rectangle, padding, accessed: performance.now(),
			};
			render.atlas.set(key, entry);
			return entry;
		};

		render.deleteFromAtlas = (key, img, deferMipmaps) => {
			render.atlas.delete(key);
			const atlasImg = render.atlasImages[img.atlasIndex];
			++atlasImg.removed;

			if (atlasImg.reservedMap === undefined || img.rectangle === undefined) {
				// this is not really an "atlas" and can't be reused
				gl.deleteTexture(atlasImg.texture);
				render.atlasImages[img.atlasIndex] = undefined;
				return;
			}
			atlasImg.reservedMap ^= img.rectangle;

			gl.bindTexture(gl.TEXTURE_2D, atlasImg.texture);
			// clear old data... probably a good idea, right?
			gl.texSubImage2D(gl.TEXTURE_2D, 0, img.x, img.y, img.w, img.h, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(img.w * img.h * 4));
			if (!deferMipmaps) gl.generateMipmap(gl.TEXTURE_2D);
		};

		render.resetCache = (filter) => {
			const DEBUG_keysDeleted = [];
			const mipmapsNeeded = new Set();
			for (const [key, img] of render.atlas) {
				if (img === 'loading' || !filter(img)) continue;
				render.deleteFromAtlas(key, img, true);
				mipmapsNeeded.add(img.atlasIndex);
				DEBUG_keysDeleted.push(key);
			}

			console.log('render.resetCache:', DEBUG_keysDeleted);

			for (const index of mipmapsNeeded) {
				if (!render.atlasImages[index]) continue; // happens when a large image is removed
				gl.bindTexture(gl.TEXTURE_2D, render.atlasImages[index].texture);
				gl.generateMipmap(gl.TEXTURE_2D);
			}
		};
		// reload text once Ubuntu has loaded, prevents some serif fonts from being locked in
		document.fonts.addEventListener('loadingdone', () => render.resetCache(img => img.type === 'text'));

		render.externalImage = (src, isBackground) => {
			const key = 'IMG:' + src;
			let atlased = render.atlas.get(key);
			if (atlased) {
				if (atlased !== 'loading') atlased.accessed = now;
				return atlased;
			}
			
			render.atlas.set(key, 'loading');
			const image = new Image();
			image.crossOrigin = '';
			image.addEventListener('load', () => {
				render.addToAtlas(key, image, image.width, image.height, 'image', 0, isBackground);
				render.colors.set(key, aux.dominantColor(image)); // all external images must have a dominant color
			});
			image.src = src;
			return 'loading';
		};

		render.localImage = (dbKey, isBackground) => {
			const key = 'LOC:' + dbKey;
			let atlased = render.atlas.get(key);
			if (atlased) {
				if (atlased !== 'loading') atlased.accessed = now;
				return atlased;
			}

			const database = settings.database;
			if (!database) return 'loading';

			render.atlas.set(key, 'loading');
			const req = database.transaction('images').objectStore('images').get(dbKey);
			req.addEventListener('success', () => {
				if (!req.result) return;

				const reader = new FileReader();
				reader.addEventListener('load', () => {
					const image = new Image();
					// this can cause a lot of lag (~500ms) when loading a large image for the first time
					image.addEventListener('load', () => {
						render.addToAtlas(key, image, image.width, image.height, 'local', 0, isBackground);
						render.colors.set(key, aux.dominantColor(image));
					});
					image.src = /** @type {string} */ (reader.result);
				});
				reader.readAsDataURL(req.result); // must decompress PNG/JPEG/similar
			});
			return 'loading';
		};

		const textCtx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
		render.text = (text, isMass, isSilhouette) => {
			const key = (isSilhouette ? 'SIL:' : 'TXT:') + text;
			let atlased = render.atlas.get(key);
			if (atlased) {
				if (atlased !== 'loading') atlased.accessed = now;
				return atlased;
			}

			const textSize = BASE_TEXT_SIZE * (isMass ? 0.5 * settings.massScaleFactor : settings.nameScaleFactor);
			const lineWidth = Math.ceil(textSize / 10) * settings.textOutlinesFactor;
			let font = `${textSize}px "${sigmod.settings.font || 'Ubuntu'}", Ubuntu`;
			if (isMass ? settings.massBold : settings.nameBold) font = 'bold ' + font;

			textCtx.font = font;
			// if rendering an empty string (somehow) then width can be 0 with no outlines
			textCtx.canvas.width = (textCtx.measureText(text).width + lineWidth * 4) || 1; // clears the canvas
			textCtx.canvas.height = textSize * 3; // give sufficient space for fancy decorator characters

			textCtx.font = font; // setting .width clears canvas state
			textCtx.lineJoin = 'round';
			textCtx.lineWidth = lineWidth;
			textCtx.fillStyle = isSilhouette ? '#000' : '#fff';
			textCtx.strokeStyle = '#000';
			textCtx.textBaseline = 'middle';

			// TODO: shadow blur

			// add a space, which is to prevent sigmod from detecting the name
			if (lineWidth > 0) textCtx.strokeText(text + ' ', lineWidth * 2, textSize * 1.5);
			textCtx.shadowColor = 'transparent';
			textCtx.fillText(text + ' ', lineWidth * 2, textSize * 1.5);

			const data = textCtx.getImageData(0, 0, textCtx.canvas.width, textCtx.canvas.height);
			return render.addToAtlas(key, data, textCtx.canvas.width, textCtx.canvas.height, 'text', lineWidth * 3.75);
		};

		let circleCellBuffer = new Float32Array(0);
		let circleCellAlphaBuffer = new Float32Array(0);
		let circlePelletBuffer = new Float32Array(0);
		let circlePelletAlphaBuffer = new Float32Array(0);
		let circlePelletsUploaded = 0;
		render.uploadPellets = false;

		let playerBuffer = new Float32Array(0);

		let tracerFloats = new Float32Array(0);

		// firefox (and certain devices) adds some padding to uniform buffer sizes, so best to check its size
		gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Border);
		const borderUboBuffer = new ArrayBuffer(gl.getBufferParameter(gl.UNIFORM_BUFFER, gl.BUFFER_SIZE));
		// must reference an arraybuffer for the memory to be shared between these views
		const borderUboFloats = new Float32Array(borderUboBuffer);
		const borderUboInts = new Int32Array(borderUboBuffer);

		gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Circle);
		const circleUboFloats = new Float32Array(gl.getBufferParameter(gl.UNIFORM_BUFFER, gl.BUFFER_SIZE) / 4);

		gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Tracer);
		const tracerUboFloats = new Float32Array(gl.getBufferParameter(gl.UNIFORM_BUFFER, gl.BUFFER_SIZE) / 4);

		gl.bindBuffer(gl.UNIFORM_BUFFER, null); // leaving uniform buffer bound = scary!


		let lastTextSettingsCheck = performance.now();
		let lastTextSettings = [];
		let lastCacheClean = performance.now();
		render.fps = 0;
		render.lastFrame = performance.now();
		render.BACKGROUND = null;
		const renderGame = () => {
			now = performance.now();
			const dt = Math.max(now - render.lastFrame, 0.1) / 1000; // there's a chance (now - lastFrame) can be 0
			render.fps += (1 / dt - render.fps) / 10;
			render.lastFrame = now;

			if (gl.isContextLost()) return requestAnimationFrame(renderGame);

			// get settings
			const defaultVirusSrc = '/assets/images/viruses/2.png';
			const virusSrc = sigmod.settings.virusImage || defaultVirusSrc;
			const { cellColor, foodColor, outlineColor, showNames } = sigmod.settings

			const vision = aux.require(world.views.get(world.selected), 'no selected vision (BAD BUG)');
			vision.used = performance.now();

			if (settings.multibox && settings.mergeCamera && settings.camera === 'default') {
				settings.camera = 'natural';
				settings.save();
				settings.refresh();
			}
			world.cameras(now);

			// delete text if any related settings were changed
			if (now - lastTextSettingsCheck > 200) {
				// the delay here is so you can drag the sliders without destroying your framerate
				lastTextSettingsCheck = now;
				const s = [settings.nameBold, settings.nameScaleFactor, settings.massBold, settings.massScaleFactor, settings.textOutlinesFactor, sigmod.settings.font];
				const l = lastTextSettings;
				if (s[0] !== l[0] || s[1] !== l[1] || s[2] !== l[2] || s[3] !== l[3] || s[4] !== l[4] || s[5] !== l[5]) {
					render.resetCache(img => img.type === 'text');
					lastTextSettings = s;
				}
			}

			// delete old assets that haven't been used in a while
			if (now - lastCacheClean > 60_000) {
				lastCacheClean = now;
				render.resetCache(img => now - img.accessed > 60_000);
			}

			// note: most routines are named, for benchmarking purposes
			(function setGlobalUniforms() {
				// note that binding the same buffer to gl.UNIFORM_BUFFER twice in a row causes it to not update.
				// why that happens is completely beyond me but oh well.
				// for consistency, we always bind gl.UNIFORM_BUFFER to null directly after updating it.
				gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Camera);
				gl.bufferSubData(gl.UNIFORM_BUFFER, 0, new Float32Array([
					ui.game.canvas.width / ui.game.canvas.height, vision.camera.scale / 540,
					vision.camera.x, vision.camera.y,
				]));
				gl.bindBuffer(gl.UNIFORM_BUFFER, null);

				gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Player);
				gl.bufferSubData(gl.UNIFORM_BUFFER, 0, new Float32Array([
					...settings.outlineMultiColor, // cell_active_outline
					...settings.outlineMultiInactiveColor, // cell_inactive_outline
					...settings.unsplittableColor, // cell_unsplittable_outline
					...(outlineColor ?? [0, 0, 0, 0]), // cell_subtle_outline_override
					...(sigmod.settings.nameColor1 ?? [1, 1, 1, 1]), // cell_name_color1
					...(sigmod.settings.nameColor2 ?? [1, 1, 1, 1]), // cell_name_color2
					settings.outlineMulti,
				]));
				gl.bindBuffer(gl.UNIFORM_BUFFER, null);
			})();

			(function background() {
				if (sigmod.settings.mapColor) gl.clearColor(...sigmod.settings.mapColor);
				else if (aux.settings.darkTheme) gl.clearColor(0x11 / 255, 0x11 / 255, 0x11 / 255, 1); // #111
				else gl.clearColor(0xf2 / 255, 0xfb / 255, 0xff / 255, 1); // #f2fbff
				gl.clear(gl.COLOR_BUFFER_BIT);

				gl.useProgram(glconf.programs.bg);
				gl.bindVertexArray(glconf.backgroundVao);

				let img = render.BACKGROUND;
				if (settings.background?.startsWith('🖼️')) img = render.localImage('background', true);
				else if (settings.background) img = render.externalImage(settings.background, true);
				else if (aux.settings.showGrid) {
					img = render.externalImage(aux.settings.darkTheme ? darkGridSrc : lightGridSrc, true);
				}

				if (img && img !== 'loading') gl.bindTexture(gl.TEXTURE_2D, render.atlasImages[img.atlasIndex].texture);
				else gl.bindTexture(gl.TEXTURE_2D, null);
				const repeating = img && img !== 'loading' && img.w <= 512 && img.h <= 512;

				let borderColor;
				let borderLrtb;
				borderColor = (aux.settings.showBorder && vision.border) ? [0, 0, 1, 1] /* #00ff */
					: [0, 0, 0, 0] /* transparent */;
				borderLrtb = vision.border || { l: 0, r: 0, t: 0, b: 0 };

				borderUboFloats.set(borderColor, 0); // u_border_color
				borderUboFloats.set([borderLrtb.l, borderLrtb.r, borderLrtb.t, borderLrtb.b], 4); // u_border_xyzw_lrtb
				// flags
				borderUboInts[8] = ((img && img !== 'loading') ? 0x01 : 0) | (aux.settings.darkTheme ? 0x02 : 0)
					| (repeating ? 0x04 : 0) | (settings.rainbowBorder ? 0x08 : 0);

				borderUboFloats[9] = img?.w ?? 1; // u_background_width
				borderUboFloats[10] = img?.h ?? 1; // u_background_height
				borderUboFloats[11] = now / 1000 * 0.2 % (Math.PI * 2); // u_border_time

				gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Border);
				gl.bufferSubData(gl.UNIFORM_BUFFER, 0, borderUboFloats);
				gl.bindBuffer(gl.UNIFORM_BUFFER, null);
				gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
			})();

			// used in cells() and minimap()
			const ownedByMe = new Map();
			for (const vision of world.views.values()) {
				for (const id of vision.owned) ownedByMe.set(id, vision.view);
			}

			(function cells() {
				// don't render anything if the current tab is not connected
				const con = net.connections.get(world.selected);
				if (!con?.handshake) return;

				const cellOrPelletAlpha = cell => {
					let alpha = (now - cell.born) / settings.drawDelay;
					if (cell.deadAt) {
						let alpha2 = 1 - (now - cell.deadAt) / settings.drawDelay;
						if (alpha2 < alpha) alpha = alpha2;
					}
					return alpha < 0 ? 0 : alpha > 1 ? 1 : alpha;
				};

				const cellSkin = (cell, cellOwner) => {
					if (cellOwner === world.viewId.primary && settings.selfSkin) {
						if (settings.selfSkin.startsWith('🖼️')) return render.localImage('selfSkin');
						else return render.externalImage(settings.selfSkin);
					} else if (cellOwner === world.viewId.secondary && settings.selfSkinMulti) {
						if (settings.selfSkinMulti.startsWith('🖼️')) return render.localImage('selfSkinMulti');
						else return render.externalImage(settings.selfSkinMulti);
					} else if (aux.settings.showSkins && cell.skin) {
						return render.externalImage(cell.skin);
					}
				};

				// #1 : non-deadTo pellets
				let ccBuf = circleCellBuffer;
				let cpBuf = circlePelletBuffer;
				let pBuf = playerBuffer;
				gl.useProgram(glconf.programs.circle);
				gl.bindVertexArray(glconf.circlePelletVao);
				circleUboFloats[0] = 1; // u_circle_alpha
				circleUboFloats[1] = 0; // u_circle_scale (no glow)
				gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Circle);
				gl.bufferSubData(gl.UNIFORM_BUFFER, 0, circleUboFloats);

				if (render.uploadPellets) {
					// resize buffers if needed
					if (world.pellets.size > circlePelletAlphaBuffer.length) {
						let newLength = circlePelletAlphaBuffer.length || 1;
						while (world.pellets.size > newLength) newLength *= 2;

						circlePelletBuffer = cpBuf = new Float32Array(newLength * 7);
						gl.bindBuffer(gl.ARRAY_BUFFER, glconf.circlePelletBuffer);
						gl.bufferData(gl.ARRAY_BUFFER, circlePelletBuffer.byteLength, gl.STATIC_DRAW);
						circlePelletAlphaBuffer = new Float32Array(newLength);
						gl.bindBuffer(gl.ARRAY_BUFFER, glconf.circlePelletAlphaBuffer);
						gl.bufferData(gl.ARRAY_BUFFER, circlePelletAlphaBuffer.byteLength, gl.STATIC_DRAW);
					}

					let o = 0;
					circlePelletsUploaded = 0;
					for (const pellet of world.pellets.values()) {
						if (pellet.deadTo) continue;
						cpBuf[o++] = pellet.tx; cpBuf[o++] = pellet.ty; cpBuf[o++] = pellet.tr;

						let { red, green, blue } = pellet;
						let alpha = 1;
						if (foodColor) [red, green, blue, alpha] = foodColor;
						cpBuf[o++] = red; cpBuf[o++] = green; cpBuf[o++] = blue; cpBuf[o++] = alpha;

						++circlePelletsUploaded;
					}

					gl.bindBuffer(gl.ARRAY_BUFFER, glconf.circlePelletBuffer);
					gl.bufferSubData(gl.ARRAY_BUFFER, 0, circlePelletBuffer);

					render.uploadPellets = false;
				}

				let i = 0;
				let numPlayerElements = 0;
				let pbo = 0;
				for (const pellet of world.pellets.values()) {
					if (pellet.deadTo) ++numPlayerElements;
					else circlePelletAlphaBuffer[i++] = cellOrPelletAlpha(pellet);
				}
				gl.bindBuffer(gl.ARRAY_BUFFER, glconf.circlePelletAlphaBuffer);
				gl.bufferSubData(gl.ARRAY_BUFFER, 0, circlePelletAlphaBuffer);

				if (settings.pelletGlow && aux.settings.darkTheme) gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
				gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, circlePelletsUploaded);

				// #2 : pellet glow, drawn below cells
				if (settings.pelletGlow) {
					gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
					circleUboFloats[0] = 0.25; // u_circle_alpha
					circleUboFloats[1] = 2; // u_circle_scale
					gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Circle);
					gl.bufferSubData(gl.UNIFORM_BUFFER, 0, circleUboFloats);

					gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, circlePelletsUploaded);
				}

				// #3 : deadTo pellets
				gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
				gl.useProgram(glconf.programs.player);
				gl.bindVertexArray(glconf.playerVao);

				const cellsSorted = [];
				for (const cell of world.cells.values()) {
					const killer = cell.deadTo ? world.cells.get(cell.deadTo) : undefined;
					const xyr = world.xyr(cell, killer, now);
					cellsSorted.push([cell, xyr, settings.jellyPhysics ? xyr.jr : xyr.r]);
					++numPlayerElements;
					// overallocate a little for the text, don't want to compute much
					if (cell.tr >= 64) numPlayerElements += 2 + String(cell.tr * cell.tr / 100).length;
				}
				cellsSorted.sort((a, b) => a[2] - b[2]);

				if (numPlayerElements * 17 > playerBuffer.length) {
					let newLength = playerBuffer.length || 1;
					while (numPlayerElements * 17 > newLength) newLength *= 2;

					playerBuffer = pBuf = new Float32Array(newLength);
					gl.bindBuffer(gl.ARRAY_BUFFER, glconf.playerBuffer);
					gl.bufferData(gl.ARRAY_BUFFER, playerBuffer.byteLength, gl.STATIC_DRAW);
				}

				for (const pellet of world.pellets.values()) {
					if (!pellet.deadTo) continue;
					const killer = world.cells.get(pellet.deadTo);
					const xyr = world.xyr(pellet, killer, now);

					let { red, green, blue } = pellet;
					let alpha = 1;
					if (foodColor) [red, green, blue, alpha] = foodColor;
					pBuf.set([
						xyr.x, xyr.y, xyr.r, xyr.r, // square x,y,w,h
						0, 0, 0, 0, -1, // texture x,y,w,h, and index
						0, 0, -1, // silhouette x,y, and index
						red, green, blue, alpha * cellOrPelletAlpha(pellet),
						1 | 0x20, // flags: cell (1) | color under skin (0x20)
					], pbo);
					pbo += 17;
				}

				// #4 : player cells and text
				// for white cell outlines
				let nextCellIdx = 0;
				const unsplittable = new Set();
				for (const id of vision.owned) {
					const cell = world.cells.get(id);
					if (cell && !cell.deadAt) ++nextCellIdx; // this counts towards the split limit
				}
				for (const id of vision.owned) {
					const cell = world.cells.get(id);
					if (!cell || cell.deadAt) continue;
					// cells under 128 radius can't split; cells that would make a 17th (or above) cell can't split
					if (cell.tr < 128 || nextCellIdx++ >= 16) unsplittable.add(id);
				}

				const digits = [];
				for (let i = 0; i < 10; ++i) digits[i] = render.text(String(i), true, false);

				const usedTextureIndices = new Set();
				for (const [cell, xyr] of cellsSorted) {
					const { x, y, r, jr } = xyr;
					const alpha = cellOrPelletAlpha(cell);
					pBuf[pbo++] = x; pBuf[pbo++] = y; // a_pos.xy
					// without jelly physics, the radius of cells is adjusted such that its subtle outline doesn't go
					// past its original radius.
					// jelly physics does not do this, so colliding cells need to look kinda 'joined' together,
					// so we multiply the radius by 1.02 (approximately the size increase from the stroke thickness)
					let skinScale = 1;
					if (aux.settings.jellyPhysics && !cell.jagged) {
						const strokeThickness = Math.max(jr * 0.01, 10);
						pBuf[pbo++] = pBuf[pbo++] = jr + strokeThickness; // a_size.xy
						if (settings.jellySkinLag) {
							skinScale = Math.min((jr + strokeThickness) / (r + strokeThickness), 1);
						}
					} else {
						pBuf[pbo++] = pBuf[pbo++] = r + 2; // a_size.xy
					}

					if (cell.jagged) {
						// special case
						const img = render.externalImage(virusSrc);
						if (img !== 'loading') {
							// a_texture_pos.xy, a_texture_size.xy
							pBuf[pbo++] = img.x; pBuf[pbo++] = img.y; pBuf[pbo++] = img.w; pBuf[pbo++] = img.h;
							pBuf[pbo++] = img.atlasIndex; // a_texture_index
							usedTextureIndices.add(img.atlasIndex);

							pBuf[pbo++] = pBuf[pbo++] = 0; // a_silhouette_pos.xy
							pBuf[pbo++] = -1; // a_silhouette_index

							pBuf[pbo++] = pBuf[pbo++] = pBuf[pbo++] = 0; // a_color.rgb
							// a_color.a: draw default viruses twice as strong for better contrast against light theme
							pBuf[pbo++] = ((!aux.settings.darkTheme && virusSrc === defaultVirusSrc) ? 1.5 : 1) * alpha
								* settings.cellOpacity;
							pBuf[pbo++] = 1; // a_flags: cell (1)
						} else {
							// a_texture_pos.xy, a_texture_size.xy
							pBuf[pbo++] = pBuf[pbo++] = pBuf[pbo++] = pBuf[pbo++] = 0;
							pBuf[pbo++] = -1; // a_texture_index

							pBuf[pbo++] = pBuf[pbo++] = 0; // a_silhouette_pos.xy
							pBuf[pbo++] = -1; // a_silhouette_index

							pBuf[pbo++] = 1; pBuf[pbo++] = 0; pBuf[pbo++] = 0;
							pBuf[pbo++] = alpha * settings.cellOpacity; // a_color (red)
							pBuf[pbo++] = 1 | 0x20; // a_flags: cell (1) | color under skin (0x20)
						}
						continue;
					}

					const cellOwner = ownedByMe.get(cell.id);
					const img = cellSkin(cell, cellOwner);
					if (img && img !== 'loading') {
						// a_texture_pos.xy, a_texture_size.xy
						pBuf[pbo++] = img.x + img.w * (1 - skinScale) / 2; // a_texture_pos.x
						pBuf[pbo++] = img.y + img.h * (1 - skinScale) / 2; // a_texture_pos.y
						pBuf[pbo++] = img.w * skinScale; pBuf[pbo++] = img.h * skinScale; // a_texture_pos.xy
						pBuf[pbo++] = img.atlasIndex; // a_texture_index
						usedTextureIndices.add(img.atlasIndex);
					} else {
						// a_texture_pos.xy, a_texture_size.xy
						pBuf[pbo++] = pBuf[pbo++] = pBuf[pbo++] = pBuf[pbo++] = 0;
						pBuf[pbo++] = -1; // a_texture_index
					}

					pBuf[pbo++] = pBuf[pbo++] = 0; // a_silhouette_pos.xy
					pBuf[pbo++] = -1; // a_silhouette_index

					// a_color
					if (cellColor) {
						pBuf[pbo++] = cellColor[0]; pBuf[pbo++] = cellColor[1]; pBuf[pbo++] = cellColor[2];
						pBuf[pbo++] = cellColor[3] * alpha * settings.cellOpacity;
					} else {
						pBuf[pbo++] = cell.red; pBuf[pbo++] = cell.green; pBuf[pbo++] = cell.blue;
						pBuf[pbo++] = alpha * settings.cellOpacity;
					}

					let flags = 1; // cell
					if (settings.cellOutlines) flags |= 2; // subtle outlines (or custom cell outlines)
					if (vision.camera.merged && cellOwner === world.selected) flags |= 4; // active
					if (vision.camera.merged && cellOwner && cellOwner !== world.selected) flags |= 8; // inactive
					if (unsplittable.has(cell.id)) flags |= 0x10; // unsplittable
					if (!cell.skin || settings.colorUnderSkin) flags |= 0x20; // color under skin
					pBuf[pbo++] = flags; // a_flags

					// now text
					const lineHeight = r * 0.15; // note: images are 3x taller than line height
					const baseLine = y;
					let massLine = baseLine + lineHeight * (settings.nameScaleFactor + settings.massScaleFactor / 2);
					let clanLine = baseLine - lineHeight * (settings.nameScaleFactor + settings.clanScaleFactor / 2);
					const hasName = (showNames ?? true) && cell.tr >= 64;
					const hasMass = (aux.settings.showMass ?? true) && cell.tr >= 64;
					const clanName = settings.clans && cell.clan && aux.clans.get(cell.clan);
					if (!hasName) {
						// this only does something if showNames is off
						if (!clanName) massLine = baseLine;
						else clanLine = baseLine;
					}

					const useNameColor = cellOwner && sigmod.settings.nameColor1;
					const useSilhouette = useNameColor || cell.sub;

					if (clanName) {
						const img = render.text(clanName, hasName ? false : true, false);
						const imgHeight = lineHeight * 3 * settings.clanScaleFactor * (hasName ? 0.5 : 1);

						pBuf[pbo++] = x; pBuf[pbo++] = clanLine; // a_pos.xy
						pBuf[pbo++] = imgHeight / img.h * img.w; pBuf[pbo++] = imgHeight; // a_size.xy
						// a_texture_pos.xy, a_texture_size.xy
						pBuf[pbo++] = img.x; pBuf[pbo++] = img.y; pBuf[pbo++] = img.w; pBuf[pbo++] = img.h;
						usedTextureIndices.add(pBuf[pbo++] = img.atlasIndex); // a_texture_index

						if (useSilhouette) {
							const sil = render.text(clanName, hasName ? false : true, true);
							pBuf[pbo++] = sil.x; pBuf[pbo++] = sil.y; // a_silhouette_pos.xy
							usedTextureIndices.add(pBuf[pbo++] = sil.atlasIndex); // a_silhouette_index
						} else {
							pBuf[pbo++] = pBuf[pbo++] = 0; // a_silhouette_pos.xy
							pBuf[pbo++] = -1; // a_silhouette_index
						}

						// a_color, always white, modified in vertex shader
						pBuf[pbo++] = pBuf[pbo++] = pBuf[pbo++] = 1; pBuf[pbo++] = alpha;
						// a_flags
						pBuf[pbo++] = (useSilhouette ? 0x40 : 0) | (useNameColor ? 0x80 : 0) | (cell.sub ? 0x100 : 0);
					}

					if (hasName) {
						const img = render.text(cell.name, false, false);
						const imgHeight = lineHeight * 3 * settings.nameScaleFactor;

						pBuf[pbo++] = x; pBuf[pbo++] = y; // a_pos.xy
						pBuf[pbo++] = imgHeight / img.h * img.w; pBuf[pbo++] = imgHeight; // a_size.xy
						// a_texture_pos.xy, a_texture_size.xy
						pBuf[pbo++] = img.x; pBuf[pbo++] = img.y; pBuf[pbo++] = img.w; pBuf[pbo++] = img.h;
						usedTextureIndices.add(pBuf[pbo++] = img.atlasIndex); // a_texture_index

						if (useSilhouette) {
							const sil = render.text(cell.name, false, true);
							pBuf[pbo++] = sil.x; pBuf[pbo++] = sil.y; // a_silhouette_pos.xy
							usedTextureIndices.add(pBuf[pbo++] = sil.atlasIndex); // a_silhouette_index
						} else {
							pBuf[pbo++] = pBuf[pbo++] = 0; // a_silhouette_pos.xy
							pBuf[pbo++] = -1; // a_silhouette_index
						}

						pBuf[pbo++] = pBuf[pbo++] = pBuf[pbo++] = 1; pBuf[pbo++] = alpha; // a_color (white)
						// a_flags
						pBuf[pbo++] = (useSilhouette ? 0x40 : 0) | (useNameColor ? 0x80 : 0) | (cell.sub ? 0x100 : 0);
					}

					if (hasMass) {
						// draw each digit separately, significantly reduces the size of the text cache
						const str = String(Math.floor(cell.tr * cell.tr / 100));
						const imgHeight = lineHeight * 3 * settings.massScaleFactor * 0.5;

						let totalWidth = 0;
						for (let i = 0; i < str.length; ++i) {
							const digit = digits[str[i]];
							totalWidth += (digit.w - digit.padding) * 2;
						}

						let sumWidth = 0;
						for (let i = 0; i < str.length; ++i) {
							const img = digits[str[i]];
							sumWidth += img.w - img.padding;

							const xOffset = sumWidth - totalWidth / 2;
							pBuf[pbo++] = x + imgHeight / img.h * xOffset; pBuf[pbo++] = massLine; // a_pos.xy
							pBuf[pbo++] = imgHeight / img.h * img.w; pBuf[pbo++] = imgHeight; // a_size.xy
							// a_texture_pos.xy, a_texture_size.xy
							pBuf[pbo++] = img.x; pBuf[pbo++] = img.y; pBuf[pbo++] = img.w; pBuf[pbo++] = img.h;
							usedTextureIndices.add(pBuf[pbo++] = img.atlasIndex); // a_texture_index

							pBuf[pbo++] = pBuf[pbo++] = 0; // a_silhouette_pos.xy
							pBuf[pbo++] = -1; // a_silhouette_index

							// a_color (white)
							pBuf[pbo++] = pBuf[pbo++] = pBuf[pbo++] = 1; pBuf[pbo++] = alpha * settings.massOpacity;
							// a_flags : 0x80 | 0x100 | ignore gradient (0x200)
							pBuf[pbo++] = (useNameColor ? 0x80 : 0) | (cell.sub ? 0x100 : 0) | 0x200; // a_flags

							sumWidth += img.w - img.padding;
						}
					}
				}
				gl.bindBuffer(gl.ARRAY_BUFFER, glconf.playerBuffer);
				gl.bufferSubData(gl.ARRAY_BUFFER, 0, playerBuffer);

				for (const index of usedTextureIndices) {
					gl.activeTexture(gl.TEXTURE0 + index);
					gl.bindTexture(gl.TEXTURE_2D, render.atlasImages[index].texture);
				}
				gl.activeTexture(gl.TEXTURE0); // default

				gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, pbo / 17);

				// #5 : cell glow (not including deadTo pellets)
				if (settings.cellGlow) {
					gl.useProgram(glconf.programs.circle);
					gl.bindVertexArray(glconf.circleCellVao);
					gl.blendFunc(gl.SRC_ALPHA, gl.ONE);

					circleUboFloats[0] = 0.25; // u_circle_alpha
					// u_circle_scale (can't be too big, otherwise it looks weird when cells come into view)
					circleUboFloats[1] = 1.5;
					gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Circle);
					gl.bufferSubData(gl.UNIFORM_BUFFER, 0, circleUboFloats);

					// resize buffers if needed
					if (cellsSorted.length > circleCellAlphaBuffer.length) {
						let newLength = circleCellAlphaBuffer.length || 1;
						while (cellsSorted.length * 7 > newLength) newLength *= 2;

						circleCellBuffer = ccBuf = new Float32Array(newLength * 7);
						gl.bindBuffer(gl.ARRAY_BUFFER, glconf.circleCellBuffer);
						gl.bufferData(gl.ARRAY_BUFFER, circleCellBuffer.byteLength, gl.STATIC_DRAW);
						circleCellAlphaBuffer = new Float32Array(newLength);
						gl.bindBuffer(gl.ARRAY_BUFFER, glconf.circleCellAlphaBuffer);
						gl.bufferData(gl.ARRAY_BUFFER, circleCellAlphaBuffer.byteLength, gl.STATIC_DRAW);
					}

					let o = 0;
					let uploaded = 0;
					for (const [cell, xyr] of cellsSorted) {
						if (cell.jagged) continue; // viruses look weird with glow
						ccBuf[o++] = xyr.x; ccBuf[o++] = xyr.y; ccBuf[o++] = xyr.r;

						let { red, green, blue } = cell;
						let alpha = 1;
						if (cellColor) [red, green, blue, alpha] = cellColor;

						const cellOwner = ownedByMe.get(cell.id);
						const img = cellSkin(cell, cellOwner);
						const skinColor = img && img !== 'loading' ? render.colors.get(img.key) : undefined;
						if (skinColor) {
							alpha = skinColor[3];
							red += (skinColor[0] - red) * alpha;
							green += (skinColor[1] - green) * alpha;
							blue += (skinColor[2] - blue) * alpha;
						}

						ccBuf[o++] = red; ccBuf[o++] = green; ccBuf[o++] = blue; ccBuf[o++] = alpha;
						// it looks kinda weird when cells get sucked in when being eaten
						circleCellAlphaBuffer[uploaded++] = cellOrPelletAlpha(cell) * (cell.deadTo ? 0.25 : 1);
					}

					gl.bindBuffer(gl.ARRAY_BUFFER, glconf.circleCellBuffer);
					gl.bufferSubData(gl.ARRAY_BUFFER, 0, circleCellBuffer);
					gl.bindBuffer(gl.ARRAY_BUFFER, glconf.circleCellAlphaBuffer);
					gl.bufferSubData(gl.ARRAY_BUFFER, 0, circleCellAlphaBuffer);

					gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, uploaded);
				}

				gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
			})();

			(function tracers() {
				if (!settings.tracer) return;
				gl.useProgram(glconf.programs.tracer);
				gl.bindVertexArray(glconf.tracerVao);

				tracerUboFloats.set([ 0.5, 0.5, 0.5, 0.5, 2 ]); // #7f7f7f7f color, 2 line thickness
				gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Tracer);
				gl.bufferSubData(gl.UNIFORM_BUFFER, 0, tracerUboFloats);
				gl.bindBuffer(gl.UNIFORM_BUFFER, null);

				const mouse = input.toWorld(world.selected, input.current);
				const inputs = input.views.get(world.selected);
				if (!inputs) return;

				// resize by powers of 2
				let capacity = tracerFloats.length || 1;
				while (vision.owned.size * 4 > capacity) capacity *= 2;
				const resizing = capacity !== tracerFloats.length;
				if (resizing) tracerFloats = new Float32Array(capacity);

				let i = 0;
				for (const id of vision.owned) {
					const cell = world.cells.get(id);
					if (!cell || cell.deadAt) continue;

					const { x, y } = world.xyr(cell, undefined, now);
					tracerFloats[i * 4] = x;
					tracerFloats[i * 4 + 1] = y;
					tracerFloats[i * 4 + 2] = mouse[0];
					tracerFloats[i * 4 + 3] = mouse[1];

					switch (inputs.lock?.type) {
						case 'point':
							if (now > inputs.lock.until) break;
							tracerFloats[i * 4 + 2] = inputs.lock.world[0];
							tracerFloats[i * 4 + 3] = inputs.lock.world[1];
							break;
						case 'horizontal':
							tracerFloats[i * 4 + 3] = inputs.lock.world[1];
							break;
						case 'vertical':
							tracerFloats[i * 4 + 2] = inputs.lock.world[0];
							break;
						case 'fixed':
							const camera = world.singleCamera(vision, settings.camera !== 'default' ? 2 : 0, now);
							const dx = mouse[0] - camera.sumX / camera.weight;
							const dy = mouse[1] - camera.sumY / camera.weight;
							const d = Math.hypot(dx, dy);
							tracerFloats[i * 4 + 2] = x + dx * 1e6 / d;
							tracerFloats[i * 4 + 3] = y + dy * 1e6 / d;
					}
					++i;
				}

				gl.bindBuffer(gl.ARRAY_BUFFER, glconf.tracerBuffer);
				if (resizing) gl.bufferData(gl.ARRAY_BUFFER, tracerFloats, gl.STATIC_DRAW);
				else gl.bufferSubData(gl.ARRAY_BUFFER, 0, tracerFloats);
				gl.bindBuffer(gl.ARRAY_BUFFER, null);
				gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, i);
			})();

			ui.stats.update(world.selected);

			(function minimap() {
				if (now - lastMinimapDraw < 40) return; // should be good enough when multiboxing, may change later
				lastMinimapDraw = now;

				if (!aux.settings.showMinimap) return ui.minimap.canvas.style.display = 'none';
				else ui.minimap.canvas.style.display = '';

				const { canvas, ctx } = ui.minimap;
				// clears the canvas
				const canvasLength = canvas.width = canvas.height = Math.ceil(200 * (devicePixelRatio - 0.0001));
				const sectorSize = canvas.width / 5;

				// always use this style for drawing section and minimap names
				ctx.font = `${Math.floor(sectorSize / 3)}px "${sigmod.settings.font || 'Ubuntu'}", Ubuntu`;
				ctx.textAlign = 'center';
				ctx.textBaseline = 'middle';

				// cache the background if necessary (25 texts = bad)
				if (minimapCache && minimapCache.bg.width === canvasLength
					&& minimapCache.darkTheme === aux.settings.darkTheme) {
					ctx.putImageData(minimapCache.bg, 0, 0);
				} else {
					// draw section names
					ctx.fillStyle = '#fff';
					ctx.globalAlpha = aux.settings.darkTheme ? 0.3 : 0.7;

					const cols = ['1', '2', '3', '4', '5'];
					const rows = ['A', 'B', 'C', 'D', 'E'];
					cols.forEach((col, y) => {
						rows.forEach((row, x) => {
							ctx.fillText(row + col, (x + 0.5) * sectorSize, (y + 0.5) * sectorSize);
						});
					});

					minimapCache = {
						bg: ctx.getImageData(0, 0, canvas.width, canvas.height),
						darkTheme: aux.settings.darkTheme,
					};
				}

				const { border } = vision;
				if (!border) return;

				// sigmod overlay resizes itself differently, so we correct it whenever we need to
				/** @type {HTMLCanvasElement | null} */
				const sigmodMinimap = document.querySelector('canvas.minimap');
				if (sigmodMinimap) {
					// we need to check before updating the canvas, otherwise we will clear it
					if (sigmodMinimap.style.width !== '200px' || sigmodMinimap.style.height !== '200px')
						sigmodMinimap.style.width = sigmodMinimap.style.height = '200px';

					if (sigmodMinimap.width !== canvas.width || sigmodMinimap.height !== canvas.height)
						sigmodMinimap.width = sigmodMinimap.height = canvas.width;
				}

				const gameWidth = border.r - border.l;
				const gameHeight = border.b - border.t;

				// highlight current section
				const sectionX = Math.floor((vision.camera.x - border.l) / gameWidth * 5);
				const sectionY = Math.floor((vision.camera.y - border.t) / gameHeight * 5);
				ctx.fillStyle = settings.theme[3] ? aux.rgba2hex6(...settings.theme) : '#ff0';
				ctx.globalAlpha = 0.3;
				ctx.fillRect(sectionX * sectorSize, sectionY * sectorSize, sectorSize, sectorSize);
				ctx.globalAlpha = 1;

				// draw cells
				const drawCell = cell => {
					const x = (cell.tx - border.l) / gameWidth * canvas.width;
					const y = (cell.ty - border.t) / gameHeight * canvas.height;
					const r = Math.max(cell.tr / gameWidth * canvas.width, 2);

					ctx.scale(0.01, 0.01); // prevent sigmod from treating minimap cells as pellets
					ctx.fillStyle = aux.rgba2hex6(cell.red, cell.green, cell.blue, 1);
					ctx.beginPath();
					ctx.moveTo((x + r) * 100, y * 100);
					ctx.arc(x * 100, y * 100, r * 100, 0, 2 * Math.PI);
					ctx.fill();
					ctx.resetTransform();
				};

				const drawName = (x, y, name) => {
					x = (x - border.l) / gameWidth * canvas.width;
					y = (y - border.t) / gameHeight * canvas.height;

					ctx.fillStyle = '#fff';
					// add a space to prevent sigmod from detecting names
					ctx.fillText(name + ' ', x, y - 7 * devicePixelRatio - sectorSize / 6);
				};

				// draw clanmates (and other tabs) first, below yourself
				// clanmates are grouped by name AND color, ensuring they stay separate
				/** @type {Map<string, { name: string, n: number, x: number, y: number }>} */
				const avgPos = new Map();
				for (const cell of world.cells.values()) {
					if (cell.deadAt) continue;

					const owner = ownedByMe.get(cell.id);
					if (owner === world.selected) continue; // skip selected cells, will be drawn on top
					if (!owner && (!cell.clan || cell.clan !== aux.userData?.clan)) continue;
					drawCell(cell);

					const hash = cell.name + (cell.red * 65536 + cell.green * 256 + cell.blue);
					const entry = avgPos.get(hash);
					if (entry) {
						++entry.n;
						entry.x += cell.tx;
						entry.y += cell.ty;
					} else {
						avgPos.set(hash, { name: cell.name, n: 1, x: cell.tx, y: cell.ty });
					}
				}

				for (const entry of avgPos) drawName(entry.x / entry.n, entry.y / entry.n, entry.name);

				// draw my cells above everyone else
				let myName = '', ownN = 0, ownX = 0, ownY = 0;
				for (const id of vision.owned) {
					const cell = world.cells.get(id);
					if (!cell || cell.deadAt) continue;

					drawCell(cell);
					myName = cell.name;
					++ownN;
					ownX += cell.tx;
					ownY += cell.ty;
				}

				if (ownN <= 0) {
					// if no cells were drawn, draw our spectate pos instead
					let red = 1, green = 0.6, blue = 0.6, _alpha = 1;
					if (settings.theme[3]) [red, green, blue, _alpha] = settings.theme;
					drawCell({
						tx: vision.camera.x, ty: vision.camera.y, tr: gameWidth / canvas.width * 5, red, green, blue,
					});
				} else {
					ownX /= ownN;
					ownY /= ownN;
					// draw name above player's cells
					drawName(ownX, ownY, myName);
				}
			})();

			requestAnimationFrame(renderGame);
		}

		requestAnimationFrame(renderGame);
		return render;
	})();



	// @ts-expect-error for debugging purposes and other scripts. dm me on discord @ 8y8x to guarantee stability
	window.sigfix = { aux, sigmod, ui, settings, world, net, input, glconf, render };
})();