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 or Violentmonkey 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.7.8
// @description  Easily 10X your FPS on Sigmally.com + many bug fixes + great for multiboxing + supports SigMod
// @author       8y8x
// @match        https://*.sigmally.com/*
// @license      MIT
// @grant        none
// @namespace    https://8y8x.dev/sigmally-fixes
// @icon         https://raw.githubusercontent.com/8y8x/sigmally-fixes/refs/heads/main/icon.png
// @compatible   chrome
// @compatible   opera
// @compatible   edge
// ==/UserScript==

// @ts-check
/* 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';

(async () => {
	const sfVersion = '2.7.8';
	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();
		function fetchClans() {
			fetch('https://sigmally.com/api/clans').then(r => r.json()).then(r => {
				if (r.status !== 'success') {
					setTimeout(() => fetchClans(), 10_000);
					return;
				}

				aux.clans.clear();
				r.data.forEach(clan => {
					if (typeof clan._id !== 'string' || typeof clan.name !== 'string') return;
					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(err => {
				console.warn('Error while fetching clans:', err);
				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) {
				err = '[Sigmally Fixes]: ' + err;
				prompt(err, err); // use prompt, so people can paste the error message into google translate
				throw 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 d = Math.hypot((3 - x) / 6, (3 - y) / 6);
					if (d > 1) 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)] / 255, g[Math.ceil(g.length / 2)] / 255,
				b[Math.ceil(b.length / 2)] / 255, sumA / numA / 255];

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

			color[3] **= 4; // transparent skins should use the player color

			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 => name.match(/^\{.*?\}(.*)$/)?.[1] ?? 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;
		}

		let caught = false;
		const tabScan = new BroadcastChannel('sigfix-tabscan');
		tabScan.addEventListener('message', () => {
			if (caught || world.score(world.selected) <= 50) return;
			caught = true;
			const str = 'hi! sigmally fixes v2.5.0 is now truly one-tab, so you don\'t need multiple tabs anymore. ' +
				'set a keybind under the "One-tab multibox key" setting and enjoy!';
			prompt(str, str);
		});
		setInterval(() => {
			if (world.score(world.selected) > 50 && !caught) tabScan.postMessage(undefined);
		}, 5000);

		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));
		let lastUserData = -Infinity;
		// this is the best method i've found to get the userData object, since game.js uses strict mode
		Object.defineProperty(window, 'fetch', {
			value: new Proxy(fetch, {
				apply: (target, thisArg, args) => {
					let url = args[0];
					const data = args[1];
					if (typeof url === 'string') {
						if (url.includes('/server/recaptcha/v3'))
							return new Promise(() => {}); // block game.js from attempting to go through captcha flow

						// game.js doesn't think we're connected to a server, we default to eu0 because that's the
						// default everywhere else
						if (url.includes('/userdata/')) {
							// when holding down the respawn key, you can easily make 30+ requests a second,
							// bombing you into ratelimit hell
							const now = performance.now();
							if (now - lastUserData < 500) return new Promise(() => {});
							url = url.replace('///', '//eu0.sigmally.com/server/');
							lastUserData = now;
						}

						if (url.includes('/server/auth')) {
							// sigmod must be properly initialized (client can't be null), otherwise it will error
							// and game.js will never get the account data
							sigmod.patch();
						}

						// patch the current token in the url and body of the request
						if (aux.userData?.token) {
							// 128 hex characters surrounded by non-hex characters (lookahead and lookbehind)
							const tokenTest = /(?<![0-9a-fA-F])[0-9a-fA-F]{128}(?![0-9a-fA-F])/g;
							url = url.replaceAll(tokenTest, aux.userData.token);
							if (typeof data?.body === 'string')
								data.body = data.body.replaceAll(tokenTest, aux.userData.token);
						}

						args[0] = url;
						args[1] = data;
					}

					return target.apply(thisArg, args).then(res => new Proxy(res, {
						get: (target, prop, _receiver) => {
							if (prop !== 'json') {
								const val = target[prop];
								if (typeof val === 'function')
									return val.bind(target);
								else
									return val;
							}

							return () => target.json().then(obj => {
								if (obj?.body?.user) {
									aux.userData = obj.body.user;
									// NaN if invalid / undefined
									let updated = Number(new Date(aux.userData.updateTime));
									if (Number.isNaN(updated))
										updated = Date.now();
								}

								return obj;
							});
						},
					}));
				},
			}),
		});

		// get the latest game.js version whenever possible
		// some players are stuck on an older game.js version which does not allow signing in
		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();

		return aux;
	})();



	////////////////////////
	// Destroy Old Client //
	////////////////////////
	const destructor = (() => {
		const destructor = {};

		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 = function(fn) {
			return vanillaStack() ? -1 : oldRQA(fn);
		};

		// #2 : kill access to using a WebSocket
		destructor.realWebSocket = WebSocket;
		Object.defineProperty(window, 'WebSocket', {
			value: new Proxy(WebSocket, {
				construct(_target, argArray, _newTarget) {
					if (argArray[0].includes('sigmally.com') && vanillaStack()) {
						throw new Error('sigfix: Preventing new WebSocket() for unknown Sigmally connection');
					}

					// @ts-expect-error
					return new destructor.realWebSocket(...argArray);
				},
			}),
		});

		const cmdRepresentation = new TextEncoder().encode('/leaveworld').toString();
		destructor.realWsSend = WebSocket.prototype.send;
		WebSocket.prototype.send = function (x) {
			if (vanillaStack() && this.url.includes('sigmally.com')) {
				this.onclose = null;
				this.close();
				throw new Error('sigfix: Preventing .send on unknown Sigmally connection');
			}

			return destructor.realWsSend.apply(this, arguments);
		};

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

		return destructor;
	})();



	//////////////////////////////////
	// 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;

			/**
			 * @param {'cellColor' | 'foodColor' | 'mapColor' | 'outlineColor' | 'nameColor1' | 'nameColor2'} prop
			 * @param {any} initial
			 * @param {any[]} lookups
			 */
			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]);
			// sigmod treats the map border as cell borders for some reason
			applyColor('outlineColor', '#0000ff', [real.game?.borderColor]);
			// note: singular nameColor takes priority
			applyColor('nameColor1', '#ffffff', [
				real.game?.name?.color,
				real.game?.name?.gradient?.enabled && real.game.name.gradient.left,
			]);
			applyColor('nameColor2', '#ffffff', [
				real.game?.name?.color,
				real.game?.name?.gradient?.enabled && real.game.name.gradient.right,
			]);
			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;

			// 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) {
					// 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;

					// don't directly access the handler anywhere else
					/** @param {DataView} dat */
					sigmod.handleMessage = dat => handler.handleMessage({ data: dat.buffer });

					// override sendPacket to properly handle what sigmod expects
					/** @param {object} buf */
					handler.sendPacket = buf => {
						if ('build' in buf) buf = buf.build(); // convert sigmod/sigmally Writer class to a buffer
						if ('buffer' in buf) buf = buf.buffer;
						const dat = new DataView(/** @type {ArrayBuffer} */ (buf));
						switch (dat.getUint8(0)) {
							// case 0x00, sendPlay, isn't really used outside of secret tournaments
							case 0x10: { // used for linesplits and such
								net.move(world.selected, dat.getInt32(1, true), dat.getInt32(5, true));
								break;
							}
							// case 0x63, sendChat, already goes directly to sigfix.net.chat
							// case 0xdc, sendFakeCaptcha, is not used outside of secret tournaments
						}
					};
				}
			}
		};
		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,
			drawDelay: 120,
			jellySkinLag: true,
			massBold: false,
			massOpacity: 1,
			massScaleFactor: 1,
			mergeCamera: true,
			moveAfterLinesplit: false,
			multibox: '',
			/** @type {string[]} */
			multiNames: [],
			nameBold: false,
			nameScaleFactor: 1,
			nbox: false,
			nboxCount: 3,
			nboxCyclePair: '',
			nboxHoldKeybinds: ['', '', '', '', '', '', '', ''],
			nboxSelectKeybinds: ['', '', '', '', '', '', '', ''],
			nboxSwitchPair: '',
			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,
			rainbowBorder: false,
			scrollFactor: 1,
			selfSkin: '',
			selfSkinMulti: '',
			selfSkinNbox: ['', '', '', '', '', ''],
			slowerJellyPhysics: false,
			separateBoost: false,
			showStats: true,
			spectator: false,
			spectatorLatency: false,
			/** @type {'' | 'latest' | 'flawless'} */
			synchronization: 'flawless',
			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]),
			yx: false, // hehe
		};

		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;

			// 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) => {
			/**
			 * @param {HTMLInputElement} slider
			 * @param {HTMLInputElement} display
			 */
			const listen = (slider, display) => {
				const change = () => slider.value = display.value = settings[property].toFixed(decimals);
				onSyncs.push(change);
				change();

				/** @param {HTMLInputElement} input */
				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(aux.require(sigmod.querySelector(`#sfsm-${property}`), 'no selector match'),
				aux.require(sigmod.querySelector(`#sfsm-${property}-display`), 'no selector match'));
			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) => {
			/**
			 * @param {HTMLInputElement} input
			 * @param {boolean} isSigmod
			 */
			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];
					if (!file) return;

					const { database } = settingsExt;
					if (!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.resetDatabaseCache();
					});
					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) => {
			/**
			 * @param {HTMLInputElement} input
			 * @param {HTMLInputElement} alpha
			 */
			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) => {
			/** @param {HTMLSelectElement} input */
			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 || settings.nbox,
			'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(`Vision merging ${newTag}`,
			[dropdown('synchronization', [['flawless', 'Flawless (recommended)'], ['latest', 'Latest'], ['', 'None']])],
			() => !!settings.multibox || settings.nbox || settings.spectator,
			'How multiple connections synchronize the cells they can see. <br>' +
			'- "Flawless" ensures all connections are synchronized to be on the same ping. If one connection gets a ' +
			'lag spike, all connections will get that lag spike too. <br>' +
			'- "Latest" uses the most recent data across all connections. Lag spikes will be much less noticeable, ' +
			'however cells that are farther away might warp around and appear buggy. <br>' +
			'- "None" only shows what your current tab can see. <br>' +
			'"Flawless" is recommended for all users, however if you find it laggy you should try "Latest".');
		setting('One-tab mode', [checkbox('mergeCamera')], () => !!settings.multibox || settings.nbox,
			'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 || settings.nbox,
			'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 || settings.nbox,
			'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 || settings.nbox,
			'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 || settings.nbox,
			'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.');

		// don't allow turning on without multiboxing enabled first
		setting('N-boxing', [checkbox('nbox')], () => !!settings.multibox || settings.nbox,
			'<h1>ADVANCED USERS ONLY.</h1>' +
			'Enables multiboxing with 3 or more tabs (known as triboxing or quadboxing). <br>' +
			'Official Sigmally servers limit how many connections can be made from an IP address (usually 3). If you ' +
			'can\'t connect some of your tabs: <br>' +
			'- Try disabling the spectator tab for a third connection. <br>' +
			'- Try using proxies to connect via multiple IP addresses. <br>' +
			'- Try playing on a private server instead. <br>' +
			'When enabled, the multibox keybind above will cycle between all tabs.');
		setting('N-box tab count', [slider('nboxCount', 3, 3, 8, 1, 0)], () => settings.nbox,
			'The number of tabs to make available for selection.');
		setting('N-box change pair', [keybind('nboxCyclePair')],
			() => settings.nbox,
			'Pressing this key will cycle between selecting pairs #1/#2, #3/#4, #5/#6, and #7/#8. The last used tab ' +
			'in this pair will be selected. (Think of this as switching between multiple multibox game windows.)');
		setting('N-box switch within pair', [keybind('nboxSwitchPair')],
			() => settings.nbox,
			'Pressing this key will switch between tabs within your current pair (from #1/#2, #3/#4, #5/#6, or #7/#8).');
		for (let i = 0; i < 8; ++i) {
			setting(`N-box select tab #${i + 1}`, [keybind(i, settings.nboxSelectKeybinds)],
				() => settings.nbox && settings.nboxCount >= i + 1,
				`Pressing this key will switch to tab #${i + 1}.`);
		}
		for (let i = 0; i < 8; ++i) {
			setting(`N-box hold tab #${i + 1}`, [keybind(i, settings.nboxHoldKeybinds)],
				() => settings.nbox && settings.nboxCount >= i + 1,
				`Holding this key will temporarily switch to tab #${i + 1}. Releasing all n-box keys will return you ` +
				'to the last selected tab.');
		}
		for (let i = 2; i < 8; ++i) {
			setting(`N-box skin #${i + 1}`, [image(i, settings.selfSkinNbox, `selfSkinNbox.${i}`)],
				() => settings.nbox && settings.nboxCount >= i + 1,
				`A custom skin for tab #${i + 1}. You can drag+drop a skin here, or use a direct URL. Not ` +
				'visible to others.');
		}

		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 will be doubled. If you don\'t want that, you can separate the XP ' +
			'boost from your score.');
		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 ${newTag}`, [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(`<span style="padding: 2px 5px; border-radius: 10px; background: #76f; color: #fff;
			font-weight: bold; font-size: 0.95rem; user-select: none;">yx's secret setting</span>`,
			[checkbox('yx')], () => settings.yx, 'yx\'s top secret settings');

		// #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) {
						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);
					}
				})
				.catch(err => console.warn('Failed to check Sigmally Fixes version:', err));
		})();

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

			/** @type {HTMLCanvasElement | null} */
			const oldCanvas = document.querySelector('canvas#canvas');
			if (!oldCanvas) {
				throw 'exiting script - no canvas found';
			}

			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);

			// leave the old canvas so the old client can actually run
			oldCanvas.style.display = 'none';

			// 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)));

			const gl = aux.require(
				newCanvas.getContext('webgl2', { alpha: false, antialias: false, 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.',
			);

			game.gl = gl;

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

			function 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);
				const multiplier = (typeof aux.userData?.boost === 'number' && aux.userData.boost > Date.now()) ? 2 : 1;
				if (scoreVal > world.stats.highestScore) world.stats.highestScore = scoreVal;
				let scoreHtml;
				if (scoreVal <= 0) scoreHtml = '';
				else if (settings.separateBoost) {
					scoreHtml = `Score: ${Math.floor(scoreVal)}`;
					if (multiplier > 1) scoreHtml += ` <span style="color: #fc6;">(X${multiplier})</span>`;
				} else {
					scoreHtml = 'Score: ' + Math.floor(scoreVal * multiplier);
				}
				score.innerHTML = scoreHtml;

				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: ${settings.yx ? 'right' : 'center'}; white-space: pre; overflow: hidden;`;
			container.appendChild(linesContainer);

			/** @type {HTMLDivElement[]} */
			const lines = [];
			/** @param {{ me: boolean, name: string, sub: boolean, place: number | undefined }[]} lb */
			function 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) ?? []);
			});
		})();

		/** @type {HTMLElement} */
		const mainMenu = aux.require(
			document.querySelector('#__line1')?.parentElement,
			'Can\'t find the main menu UI. Try reloading the page?',
		);

		/** @type {HTMLElement} */
		const statsContainer = aux.require(
			document.querySelector('#__line2'),
			'Can\'t find the death screen UI. Try reloading the page?',
		);

		/** @type {HTMLElement} */
		const continueButton = aux.require(
			document.querySelector('#continue_button'),
			'Can\'t find the continue button (on death). Try reloading the page?',
		);

		/** @type {HTMLElement | null} */
		const menuLinks = document.querySelector('#menu-links');
		/** @type {HTMLElement | null} */
		const overlay = document.querySelector('#overlays');

		// sigmod uses this to detect if the menu is closed or not, otherwise this is unnecessary
		/** @type {HTMLElement | null} */
		const menuWrapper = document.querySelector('#menu-wrapper');

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

				ui.deathScreen.hide();
			} else {
				mainMenu.style.display = 'none';
				if (overlay) overlay.style.display = 'none';
				if (menuLinks) menuLinks.style.display = 'none';
				if (menuWrapper) menuWrapper.style.display = 'none';
			}

			ui.captcha.reposition();
		};

		ui.escOverlayVisible = () => escOverlayVisible;

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

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

			/** @type {HTMLElement | null} */
			const bonus = document.querySelector('#menu__bonus');

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

			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.round(world.stats.highestScore)}`;
					} else {
						bonus.style.display = 'none';
					}
				}

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

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

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

				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 reload ' +
				'the page?',
			);

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

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

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

			/**
			 * @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}. Try reloading the page?`,
				);

				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 = 'fixed';
			toggle.style.position = 'fixed';
			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';

				if (toggled) {
					toggle.style.borderTopRightRadius = toggle.style.borderBottomRightRadius = '';
					toggle.style.opacity = '';
				} else {
					toggle.style.borderTopRightRadius = toggle.style.borderBottomRightRadius = '10px';
					toggle.style.opacity = '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';

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

			function 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();

				if (lastY === undefined) {
					lastY = e.clientY;
					return;
				}

				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';
			};

			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?');

			/** @type {((grecaptcha: any) => void) | undefined} */
			let grecaptchaResolve;
			/** @type {Promise<any>} */
			const grecaptcha = new Promise(r => grecaptchaResolve = r);
			/** @type {((turnstile: any) => void) | undefined} */
			let turnstileResolve;
			/** @type {Promise<any>} */
			const turnstile = new Promise(r => turnstileResolve = r);
			let CAPTCHA2, CAPTCHA3, TURNSTILE;

			let readyCheck;
			readyCheck = setInterval(() => {
				// it's possible that recaptcha or turnstile may be removed in the future, so we be redundant to 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 = {
								execute: () => {},
								ready: () => {},
								render: () => {},
								reset: () => {},
							};
							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 = {
							execute: () => {},
							ready: () => {},
							render: () => {},
							reset: () => {},
						};
						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 = () => {
					let replacesModeButtons = false;
					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;`;
						replacesModeButtons = type !== 'v3'; // v3 is invisible, so it shouldn't hide the play buttons
					} else {
						mount.style.cssText = `position: fixed; top: 50vh; left: 50vw; transform: translate(-50%, -50%);
							z-index: 1000;`;
					}

					return replacesModeButtons;
				};

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

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

			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;
				}

				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;
		})();

		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 //
	///////////////////////////
	/**
	 * @typedef {{
	 * 	nx: number, ny: number, nr: number,
	 * 	born: number, deadAt: number | undefined, deadTo: number,
	 * }} CellFrameWritable
	 * @typedef {Readonly<CellFrameWritable>} CellFrame
	 * @typedef {{
	 * 	ox: number, oy: number, or: number,
	 * 	jr: number, a: number, updated: number,
	 * }} CellInterpolation
	 * @typedef {{
	 *  name: string, skin: string, sub: boolean, clan: string,
	 * 	rgb: [number, number, number],
	 * 	jagged: boolean, eject: boolean,
	 * }} CellDescription
	 * @typedef {CellInterpolation & CellDescription & { frames: CellFrame[] }} CellRecord
	 * @typedef {{
	 * 	id: number,
	 * 	merged: (CellFrameWritable & CellInterpolation) | undefined,
	 * 	model: CellFrame | undefined,
	 * 	views: Map<symbol, CellRecord>,
	 * }} Cell
	 * @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,
	 * }} Vision
	 */
	const world = (() => {
		const world = {};

		// #1 : define cell variables and functions
		/** @type {Map<number, Cell>} */
		world.cells = new Map();
		/** @type {Map<number, Cell>} */
		world.pellets = new Map();
		world.multis = [Symbol(), Symbol(), Symbol(), Symbol(), Symbol(), Symbol(), Symbol(), Symbol()];
		world.viewId = {
			primary: world.multis[0],
			secondary: world.multis[1],
			spectate: Symbol(),
		};
		world.selected = world.viewId.primary;
		/** @type {Map<symbol, Vision>} */
		world.views = new Map();

		world.alive = () => {
			for (const [view, vision] of world.views) {
				for (const id of vision.owned) {
					const cell = world.cells.get(id);
					// if a cell does not exist yet, we treat it as alive
					if (!cell) return true;

					const frame = cell.views.get(view)?.frames[0];
					if (frame?.deadAt === undefined) return true;
				}
			}
			return false;
		};

		/**
		 * @typedef {{ mass: number, scale: number, sumX: number, sumY: number, weight: number }} SingleCamera
		 * @param {symbol} view
		 * @param {Vision | undefined} vision
		 * @param {number} weightExponent
		 * @param {number} now
		 * @returns {SingleCamera}
		 */
		world.singleCamera = (view, vision, weightExponent, now) => {
			vision ??= world.views.get(view);
			if (!vision) return { mass: 0, scale: 1, sumX: 0, sumY: 0, weight: 0 };

			let mass = 0;
			let r = 0;
			let sumX = 0;
			let sumY = 0;
			let weight = 0;
			for (const id of (world.views.get(view)?.owned ?? [])) {
				const cell = world.cells.get(id);
				/** @type {CellFrame | undefined} */
				const frame = world.synchronized ? cell?.merged : cell?.views.get(view)?.frames[0];
				/** @type {CellInterpolation | undefined} */
				const interp = world.synchronized ? cell?.merged : cell?.views.get(view);
				if (!frame || !interp) continue;
				// don't include cells owned before respawning
				if (frame.born < vision.spawned) continue;

				if (settings.cameraMovement === 'instant') {
					const xyr = world.xyr(frame, interp, undefined, undefined, false, now);
					r += xyr.r * xyr.a;
					mass += (xyr.r * xyr.r / 100) * xyr.a;
					const cellWeight = xyr.a * (xyr.r ** weightExponent);
					sumX += xyr.x * cellWeight;
					sumY += xyr.y * cellWeight;
					weight += cellWeight;
				} else { // settings.cameraMovement === 'default'
					if (frame.deadAt !== undefined) continue;
					const xyr = world.xyr(frame, interp, undefined, undefined, false, now);
					r += frame.nr;
					mass += frame.nr * frame.nr / 100;
					const cellWeight = frame.nr ** weightExponent;
					sumX += xyr.x * cellWeight;
					sumY += xyr.y * cellWeight;
					weight += cellWeight;
				}
			}

			const scale = Math.min(64 / r, 1) ** 0.4;
			return { mass, scale, 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(view, 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 || settings.nbox) {
				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;
				let sumX = 0;
				let sumY = 0;
				let 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,
			};
			world.views.set(view, vision);
			return vision;
		};

		let wasFlawlessSynchronized = false;
		/** @type {number | undefined} */
		let disagreementAt, disagreementStart;
		world.synchronized = false;
		world.merge = () => {
			if (wasFlawlessSynchronized && settings.synchronization !== 'flawless') {
				for (const key of /** @type {const} */ (['cells', 'pellets'])) {
					for (const cell of world[key].values()) {
						for (const record of cell.views.values()) {
							for (let i = 1, l = record.frames.length; i < l; ++i) record.frames.pop();
						}
					}
				}
			}

			if (!settings.synchronization || world.views.size <= 1) {
				disagreementStart = disagreementAt = undefined;
				world.synchronized = false;
				wasFlawlessSynchronized = false;
				return;
			}

			// the obvious solution to merging tabs is to prefer the primary tab's cells, then tack on the secondary
			// tab's cells. this is fast and easy, but causes very noticeable flickering and warping when a cell enters
			// or leaves the primary tab's vision. this is what delta suffers from.
			//
			// we could instead check cells visible on both tabs to see if they share the same target x, y, and r.
			// if they all do, then the connections are synchronized and both visions can be merged.
			// this works well, however latency can fluctuate and results in connections being completely unable to
			// synchronize between themselves if they are off by at least 40ms. this happens often, and significantly
			// more to players who usually have higher ping.
			//
			// in the below approach, we keep a record of how every cell is updated (where a lower index => more recent)
			// and try to figure out the lowest possible index for all views such that they see the same frames across
			// all cells.
			// however, doing this is complicated. consider the following scenarios:
			//
			// > Scenario #1
			// > a cell is standing still and visible across multiple views. then every frame will have a perfect match
			// > between views, regardless of each connection's latency, and no frames will be ruled out.
			// > therefore, finding a perfect match across multiple indices of 0 MUST NOT imply a perfect match could
			// > be found on all other cells.
			//
			// > Scenario #2
			// > view A has been alone observing a cell for a while, and view A has abnormally high ping.
			// > that cell now comes into view B's vision, but view B has much better ping.
			// > therefore, the frame(s) view B sees of that cell will be completely disjoint from view A's.
			// > therefore, a match not existing MUST NOT imply the visions cannot be synchronized.
			//
			// > Scenario #3
			// > view A and view B have been observing a cell for a while. view A has abnormally high ping.
			// > the cell momentarily exits view B's vision, then after a few ticks re-enters its vision.
			// > once view A catches up to a frame that was missed by view B (because the cell was out of B's vision),
			// > it will not be able to find a match.
			// > therefore, this MUST NOT imply that view A's latest frames cannot be used.
			//
			// the solution involves undirected bipartite graphs representing two different views and which indices are
			// compatible with each other. for example:
			// >       (view A)                      (view B)
			// > 0 (x=12, y=34, r=56)     ┌─── 0 (x=44, y=55, r=66)
			// > 1 (x=34, y=56, r=61)     │    1 (x=55, y=66, r=77)
			// > 2 (x=44, y=55, r=66) ────┘
			// > there is a compatible connection between 2A - 0B (illustrated)
			// > there are incompatible connections between 0A - 0B, 0A - 1B, 1A - 0B, 1A - 1B, 2A - 1B
			// > there are no connections between 0A - 2B, 1A - 2B, 2A - 2B, 0A - 3B, ...etc
			//
			// >       (view A)                      (view B)
			// > 0 (x=1, y=1, r=100)   ┌────── 0 (x=2, y=2, r=100)
			// > 1 (x=2, y=2, r=100) ──┘ ┌──── 1 (x=3, y=3, r=100)
			// > 2 (x=3, y=3, r=100) ────┘ ┌── 2 (x=4, y=4, r=100)
			// > 3 (x=4, y=4, r=100) ──────┘   3 (x=5, y=5, r=101)
			// > there are compatible connections between 1A - 0B, 2A - 1B, 3A - 2B
			// > there are incompatible connections between 0A - 0B, 0A - 1B, ..., 1A - 1B, 1A - 1C, ...
			// > there are no connections between 0A - 4B, 1A - 4B, ..., 0A - 5B, ...
			//
			// then, we connect all bipartite graphs together and find the smallest indices across all views.
			// but how does this actually scale?
			//
			// if there are two views, then we start at the first view on the first index. then:
			// - if there is a compatible connection to the second view, use it, and we're done.
			// - if not, and if there is an incompatible connection, then increment index by 1. repeat.
			// - if there are no connections, then we're done.
			//
			// now if there are more views, start at the first view[1] (meaning 1st index). then:
			// - check for a compatible connection to the second view[1], [2], ..., [12].
			// - if there is a compatible connection on index i:
			//     - check for a compatible connection from second view[i] to the third view[1], [2], ..., [12].
			//     - if there is a compatible connection on index j:
			//         - first, ensure it is also compatible with the first view[1] (backwards compatibility)
			//         - if it isn't, then treat this as an incompatible connection
			//         - otherwise, if all views agree, then try checking the fourth view
			//     - if there isn't, but there is an incompatible connection, then flag a disagreement
			// - if there isn't, but there is an incompatible connection, then flag a disagreement
			// - otherwise, a "cluster" has been completely found (a collection of nearby views). go to the next view
			//   that isn't part of any cluster, and repeat
			//
			// note that if one tab sees a cell as "dead", the connection is also deemed compatible. this is to ensure
			// there are no long lag spikes while one tab leaves from another tab (for example, when the spectator tab
			// teleports away).

			const now = performance.now();
			/** @type {{ [x: number | symbol]: number }} indexed by viewInt or symbol view */
			const indices = {};

			if (settings.synchronization === 'flawless') {
				// #1 : set up bipartite graphs between every pair of views
				/** @type {Map<symbol, number>} */
				const viewToInt = new Map();
				/** @type {Map<number, symbol>} */
				const intToView = new Map();
				for (const view of world.views.keys()) {
					intToView.set(viewToInt.size, view);
					viewToInt.set(view, viewToInt.size);
				}
				const viewDim = viewToInt.size;

				// each pair of views (view1, view2, where view1Int < view2Int) has a 12x12 graph, where
				// graph[i * viewDim + j] describes the existence of an undirected connection between index i on view1
				// and index j on view2.
				const COMPATIBLE = 1 << 0;
				const INCOMPATIBLE = 1 << 1;
				const graphDim = 12; // same as maximum history size
				/** @type {Map<number, Uint8Array>} */
				const graphs = new Map();
				for (let i = 0; i < viewToInt.size; ++i) {
					for (let j = i + 1; j < viewToInt.size; ++j) {
						graphs.set(i * viewDim + j, new Uint8Array(graphDim * graphDim));
					}
				}

				// #2 : establish relationships in every graph
				// pellets never change, so it would be useless to try and compare them
				for (const cell of world.cells.values()) {
					for (const view1 of cell.views.keys()) {
						const record1 = /** @type {CellRecord} */ (cell.views.get(view1));
						for (const view2 of cell.views.keys()) {
							if (view1 === view2) continue;
							const record2 = /** @type {CellRecord} */ (cell.views.get(view2));

							const view1Int = /** @type {number} */ (viewToInt.get(view1));
							const view2Int = /** @type {number} */ (viewToInt.get(view2));
							if (view1Int > view2Int) continue; // only access graphs where view1 < view2
							const graph = /** @type {Uint8Array} */ (graphs.get(view1Int * viewDim + view2Int));

							for (let i = 0; i < record1.frames.length; ++i) {
								const frame1 = record1.frames[i];
								for (let j = 0; j < record2.frames.length; ++j) {
									const frame2 = record2.frames[j];

									if (frame1.deadAt !== undefined || frame2.deadAt !== undefined || (frame1.nx === frame2.nx && frame1.ny === frame2.ny && frame1.nr === frame2.nr)) {
										graph[i * graphDim + j] |= COMPATIBLE;
									} else {
										graph[i * graphDim + j] |= INCOMPATIBLE;
									}
								}
							}
						}
					}
				}

				// #3 : find the lowest indices across all views that are compatible with each other
				/**
				 * @param {{ viewInt: number, i: number }[]} previous
				 * @param {number} viewInt
				 * @param {number} i
				 * @returns {boolean}
				 */
				const explore = (previous, viewInt, i) => {
					// try and find the next view that is compatible
					for (let next = viewInt + 1; next < viewDim; ++next) {
						if (indices[next] !== undefined) continue;

						const graph = /** @type {Uint8Array} */ (graphs.get(viewInt * viewDim + next));
						let incompatible = false;
						for (let j = 0; j < graphDim; ++j) {
							const con = graph[i * graphDim + j];
							if (con & INCOMPATIBLE) {
								incompatible = true;
							} else if (con & COMPATIBLE) {
								// we found a connection
								// TODO: checking for backwards compatibility seems to break things
								previous.push({ viewInt, i });
								const ok = explore(previous, next, j);
								previous.pop();
								if (ok) {
									indices[next] = indices[/** @type {symbol} */ (intToView.get(next))] = j;
									return true;
								}

								incompatible = false;
							} else; // don't do anything if the connection is undefined
						}

						// if an incompatible connection was found with no other good choices, then there is a conflict
						// and don't allow it
						if (incompatible) return false;
					}

					return true;
				};

				startCluster: for (let viewInt = 0; viewInt < viewDim; ++viewInt) {
					if (indices[viewInt] !== undefined) continue; // don't re-process a cluster

					// try and start a cluster with some index
					for (let i = 0; i < graphDim; ++i) {
						if (explore([], viewInt, i)) {
							indices[viewInt] = indices[/** @type {symbol} */ (intToView.get(viewInt))] = i;
							continue startCluster;
						}
					}

					// if everything is incompatible, then there is a disagreement somewhere
					// (shouldn't really happen unless a huge lag spike hits)
					disagreementStart ??= now;
					disagreementAt = now;
					if (now - disagreementStart > 1000) world.synchronized = false;
					return;
				}

				wasFlawlessSynchronized = true;
			} else { // settings.synchronization === 'latest'
				let i = 0;
				for (const view of world.views.keys()) {
					indices[i++] = indices[view] = 0;
				}

				wasFlawlessSynchronized = false;
			}

			// #4 : find a model frame for all cells and pellets
			for (const key of /** @type {const} */ (['cells', 'pellets'])) {
				for (const cell of world[key].values()) {
					/** @type {[symbol, CellRecord] | undefined} */
					let modelPair;
					modelViewLoop: for (const pair of cell.views) {
						if (!modelPair) {
							modelPair = pair;
							continue;
						}

						const [modelView, model] = modelPair;
						const [view, record] = pair;

						const modelFrame = model.frames[indices[modelView]];
						const frame = record.frames[indices[view]];

						const modelDisappeared = !modelFrame || (modelFrame.deadAt !== undefined && modelFrame.deadTo === -1);
						const thisDisappeared = !frame || (frame.deadAt !== undefined && frame.deadTo === -1);

						if (modelDisappeared && thisDisappeared) {
							// both have currently disappeared; prefer the one that "disappeared" later
							if (settings.synchronization === 'flawless') {
								for (let off = 1; off < 12; ++off) {
									const modelFrameOffset = model.frames[indices[modelView] + off];
									const frameOffset = record.frames[indices[view] + off];

									const modelOffsetAlive = modelFrameOffset && modelFrameOffset.deadAt === undefined;
									const frameOffsetAlive = frameOffset && frameOffset.deadAt === undefined;
									if (modelOffsetAlive && frameOffsetAlive) {
										// both disappeared at the same time, doesn't matter
										continue modelViewLoop;
									} else if (modelOffsetAlive && !frameOffsetAlive) {
										// model disappeared last, so prefer it
										continue modelViewLoop;
									} else if (!modelOffsetAlive && frameOffsetAlive) {
										// current disappeared last, so prefer it
										modelPair = pair;
										continue modelViewLoop;
									} else; // we haven't found when either one disappeared
								}
							} else {
								// if (!modelFrame && !frame) leave the model as is
								// else if (modelFrame && !frame) leave the model as is
								if (!modelFrame && frame) modelPair = pair;
								else if (modelFrame && frame && /** @type {number} */ (frame.deadAt) > /** @type {number} */ (modelFrame.deadAt)) modelPair = pair;
							}
						} else if (modelDisappeared && !thisDisappeared) {
							// current is the only one visible, so prefer it
							modelPair = pair;
						} else if (!modelDisappeared && thisDisappeared) {
							// model is the only one visible, so prefer it (don't change anything)
						} else; // both are visible and synchronized, so it doesn't matter
					}

					// in very rare circumstances, this ends up being undefined, for some reason
					if (modelPair) cell.model = modelPair[1].frames[indices[modelPair[0]]] ?? cell.model;
				}
			}

			// #5 : create or update the merged frame for all cells and pellets
			for (const key of /** @type {const} */ (['cells', 'pellets'])) {
				for (const cell of world[key].values()) {
					const { model, merged } = cell;
					if (!model) { // could happen
						cell.merged = undefined;
						continue;
					}

					if (!merged || (merged.deadAt !== undefined && model.deadAt === undefined)) {
						cell.merged = {
							nx: model.nx, ny: model.ny, nr: model.nr,
							born: now, deadAt: model.deadAt !== undefined ? now : undefined, deadTo: model.deadTo,
							ox: model.nx, oy: model.ny, or: model.nr,
							jr: model.nr, a: 0, updated: now,
						};
					} else {
						if (merged.deadAt === undefined && (model.deadAt !== undefined || model.nx !== merged.nx || model.ny !== merged.ny || model.nr !== merged.nr)) {
							const xyr = world.xyr(merged, merged, undefined, undefined, key === 'pellets', now);

							merged.ox = xyr.x;
							merged.oy = xyr.y;
							merged.or = xyr.r;
							merged.jr = xyr.jr;
							merged.a = xyr.a;
							merged.updated = now;
						}

						merged.nx = model.nx;
						merged.ny = model.ny;
						merged.nr = model.nr;
						merged.deadAt = model.deadAt !== undefined ? (merged.deadAt ?? now) : undefined;
						merged.deadTo = model.deadTo;
					}
				}
			}

			// #6 : clean up history for all cells, because we don't want to ever go back in time
			for (const key of /** @type {const} */ (['cells', 'pellets'])) {
				for (const cell of world[key].values()) {
					for (const [view, record] of cell.views) {
						// leave the current frame, because .frames must have at least one element
						for (let i = indices[view] + 1, l = record.frames.length; i < l; ++i) record.frames.pop();
					}
				}
			}

			disagreementStart = undefined;
			// if there ever a disagreement that caused synchronization to be disabled, wait a bit after things
			// resolve to make sure they stay resolved
			if (disagreementAt === undefined || now - disagreementAt > 1000) world.synchronized = true;
		};

		/** @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;

				/** @type {CellFrame | undefined} */
				const frame = world.synchronized ? cell.merged : cell.views.get(view)?.frames[0];
				if (!frame || frame.deadAt !== undefined) continue;
				score += frame.nr * frame.nr / 100; // use exact score as given by the server, no interpolation
			}

			return score;
		};

		/**
		 * @param {CellFrame} frame
		 * @param {CellInterpolation} interp
		 * @param {CellFrame | undefined} killerFrame
		 * @param {CellInterpolation | undefined} killerInterp
		 * @param {boolean} pellet
		 * @param {number} now
		 * @returns {{ x: number, y: number, r: number, jr: number, a: number }}
		 */
		world.xyr = (frame, interp, killerFrame, killerInterp, pellet, now) => {
			let nx = frame.nx;
			let ny = frame.ny;
			if (killerFrame && killerInterp) {
				// animate towards the killer's interpolated position (not the 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(killerFrame, killerInterp, undefined, undefined, false, now);
				nx = killerXyr.x;
				ny = killerXyr.y;
			}

			let x, y, r, a;
			if (pellet && frame.deadAt === undefined) {
				x = nx;
				y = ny;
				r = frame.nr;
				a = 1;
			} else {
				let alpha = (now - interp.updated) / settings.drawDelay;
				alpha = alpha < 0 ? 0 : alpha > 1 ? 1 : alpha;

				x = interp.ox + (nx - interp.ox) * alpha;
				y = interp.oy + (ny - interp.oy) * alpha;
				r = interp.or + (frame.nr - interp.or) * alpha;

				const targetA = frame.deadAt !== undefined ? 0 : 1;
				a = interp.a + (targetA - interp.a) * alpha;
			}

			const dt = (now - interp.updated) / 1000;

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

		// 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;

			for (const key of /** @type {const} */ (['cells', 'pellets'])) {
				for (const [id, cell] of world[key]) {
					for (const [view, record] of cell.views) {
						const firstFrame = record.frames[0];
						const lastFrame = record.frames[record.frames.length - 1];
						if (firstFrame.deadAt !== lastFrame.deadAt) continue;
						if (lastFrame.deadAt !== undefined && now - lastFrame.deadAt >= settings.drawDelay + 200)
							cell.views.delete(view);
					}

					if (cell.views.size === 0) world[key].delete(id);
				}
			}
		};



		// #2 : define stats
		world.stats = {
			foodEaten: 0,
			highestPosition: 200,
			highestScore: 0,
			/** @type {number | undefined} */
			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),
			});
		};

		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
			}

			{
				const con = net.connections.get(view);
				if (con) con.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;

				for (const key of /** @type {const} */ (['cells', 'pellets'])) {
					for (const [id, resolution] of world[key]) {
						resolution.views.delete(view);
						if (resolution.views.size === 0) world[key].delete(id);
					}
				}

				connection.ws = undefined;
				world.merge();
				render.upload(true);

				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
						// carry forward record frames
						if (settings.synchronization === 'flawless') {
							for (const key of /** @type {const} */ (['cells', 'pellets'])) {
								for (const cell of world[key].values()) {
									const record = cell.views.get(view);
									if (!record) continue;

									record.frames.unshift(record.frames[0]);
									for (let i = 12, l = record.frames.length; i < l; ++i) record.frames.pop();
								}
							}
						}

						// (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 pellet = true;
							let killed = world.pellets.get(killedId) ?? (pellet = false, world.cells.get(killedId));
							if (!killed) continue;

							const record = killed.views.get(view);
							if (!record) continue;

							const frame = record.frames[0];
							// update interpolation using old targets
							const xyr = world.xyr(record.frames[0], record, undefined, undefined, pellet, now);
							record.ox = xyr.x;
							record.oy = xyr.y;
							record.or = xyr.r;
							record.jr = xyr.jr;
							record.a = xyr.a;
							record.updated = now;

							// update new targets (and dead-ness)
							record.frames[0] = {
								nx: frame.nx, ny: frame.ny, nr: frame.nr,
								born: frame.born, deadAt: now, deadTo: killerId,
							};

							if (pellet && vision.owned.has(killerId)) {
								++world.stats.foodEaten;
								net.food(view); // dumbass quest code go brrr
							}
						}

						// (b) : add, upd
						do {
							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);
							// (void 1 byte, "isUpdate")
							// (void 1 byte, "isPlayer")
							const sub = !!dat.getUint8(o + 9);
							o += 10;

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

							/** @type {[number, number, number] | undefined} */
							let rgb;
							if (flags & 0x02) { // update color
								rgb = [dat.getUint8(o++) / 255, dat.getUint8(o++) / 255, dat.getUint8(o++) / 255];
							}

							/** @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);
								name = aux.parseName(name);
								if (name) render.textFromCache(name, sub); // make sure the texture is ready on render
							}

							const jagged = !!(flags & 0x11); // spiked or agitated
							const eject = !!(flags & 0x20);
							const pellet = r <= 40 && !eject; // tourney servers have bigger pellets (r=40)
							const cell = (pellet ? world.pellets : world.cells).get(id);
							const record = cell?.views.get(view);
							if (record) {
								const frame = record.frames[0];
								if (frame.deadAt === undefined) {
									// update interpolation using old targets
									const xyr = world.xyr(record.frames[0], record, undefined, undefined, pellet, now);
									record.ox = xyr.x;
									record.oy = xyr.y;
									record.or = xyr.r;
									record.jr = xyr.jr;
									record.a = xyr.a;
								} else {
									// cell just reappeared, discard all old data
									record.ox = x;
									record.oy = y;
									record.or = r;
									record.jr = r;
									record.a = 0;
								}

								record.updated = now;

								// update target frame
								record.frames[0] = {
									nx: x, ny: y, nr: r,
									born: frame.born, deadAt: undefined, deadTo: -1
								};

								// update desc
								if (name !== undefined) record.name = name;
								if (skin !== undefined) record.skin = skin;
								if (rgb !== undefined) record.rgb = rgb;
								record.clan = clan;
								record.jagged = jagged;
								record.eject = eject;
							} else {
								/** @type {CellRecord} */
								const record = {
									ox: x, oy: y, or: r,
									jr: r, a: 0, updated: now,
									frames: [{
										nx: x, ny: y, nr: r,
										born: now, deadAt: undefined, deadTo: -1,
									}],
									name: name ?? '', skin: skin ?? '', sub, clan,
									rgb: rgb ?? [0.5, 0.5, 0.5],
									jagged, eject,
								};
								if (cell) {
									cell.views.set(view, record);
								} else {
									(pellet ? world.pellets : world.cells).set(id, {
										id,
										merged: undefined,
										model: undefined,
										views: new Map([[ view, record ]]),
									});
								}

								if (settings.synchronization === 'latest' && !pellet && !eject && rgb) {
									// 'latest' requires us to predict which cells we will own
									// a name + color check should be enough
									/** @type {CellDescription | undefined} */
									let base;
									for (const id of vision.owned) {
										const desc = world.cells.get(id)?.views.get(view);
										if (!desc || desc.frames[0].deadAt !== undefined) continue;
										base = desc;
										break;
									}

									if (base && name === base.name && rgb[0] === base.rgb[0] && rgb[1] === base.rgb[1] && rgb[2] === base.rgb[2]) {
										vision.owned.add(id);
									}
								}
							}
						} while (true);

						// (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;

							const record
								= (world.pellets.get(deletedId) ?? world.cells.get(deletedId))?.views.get(view);
							if (!record) continue;

							const frame = record.frames[0];
							if (frame.deadAt !== undefined) continue;
							record.frames[0] = {
								nx: frame.nx, ny: frame.ny, nr: frame.nr,
								born: frame.born, deadAt: now, deadTo: -1,
							};
							// no interpolation stuff is updated because the target positions won't be changed,
							// unlike on eat where nx and ny are set to the killer's
						}

						// (d) : finalize, upload data
						world.merge();
						world.clean();
						render.upload(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?.views.get(view);

							if (record && record.frames[0].deadAt === undefined && 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 key of /** @type {const} */ (['cells', 'pellets'])) {
							for (const cell of world[key].values()) {
								const record = cell.views.get(view);
								if (!record) continue;

								const frame = record.frames[0];
								if (settings.synchronization === 'flawless') {
									record.frames.unshift({
										nx: frame.nx, ny: frame.ny, nr: frame.nr,
										born: frame.born, deadAt: frame.deadAt ?? now, deadTo: frame.deadTo || -1,
									});
								} else {
									const frameWritable = /** @type {CellFrameWritable} */ (frame);
									frameWritable.deadAt ??= now;
									frameWritable.deadTo ||= -1;
								}
							}
						}
						world.merge();
						render.upload(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 [otherView, otherVision] of world.views) {
							for (const id of otherVision.owned) {
								const record = world.cells.get(id)?.views.get(otherView);
								if (!record) continue;
								const frame = record.frames[0];
								if (frame.deadAt !== undefined) 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[world.multis.indexOf(view) || 0].value;
								lb.push({
									me: true,
									name: aux.parseName(nick),
									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);
		};

		// #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);
		};

		/** @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));
		};
		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]);
			// skip flags
			for (let i = 0; i < msgBuf.byteLength; ++i) {
				dat.setUint8(2 + i, msgBuf[i]);
			}
			connection.ws.send(dat);
		};

		/**
		 * @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);
			if (!connection?.handshake || connection.ws?.readyState !== WebSocket.OPEN) return;

			const now = performance.now();
			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 [otherView, otherVision] of world.views) {
					if (otherView === view || world.score(otherView) <= 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 && !settings.nbox) {
				world.selected = world.viewId.primary;
				ui.captcha.reposition();
				ui.linesplit.update();
			}

			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
				const now = performance.now();
				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 key of /** @type {const} */ (['cells', 'pellets'])) {
					for (const [id, resolution] of world[key]) {
						resolution.views.delete(world.viewId.spectate);
						if (resolution.views.size === 0) world[key].delete(id);
					}
				}
			}
		}, 200);

		// dumbass quest code go brrr
		setInterval(() => {
			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;

		input.nboxSelectedPairs = [world.multis[0], world.multis[2], world.multis[4], world.multis[6]];
		input.nboxSelectedReal = world.viewId.primary;
		/** @type {Set<symbol>} */
		input.nboxSelectedTemporary = new Set();

		/** @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 => {
			const i = world.multis.indexOf(view);
			if (i <= 0) return input.nick[0]?.value || '';
			else return settings.multiNames[i - 1] || '';
		};

		/**
		 * @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 worldCenter = world.singleCamera(view, undefined, 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 && !settings.nbox && !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

				// cycle to the next tab
				const tabs = settings.nbox ? settings.nboxCount : 2;
				const i = world.multis.indexOf(world.selected);
				const newI = Math.min((i + 1) % tabs, world.multis.length);
				input.nboxSelectedNonTemporary = world.multis[newI];

				input.nboxSelectedTemporary.clear();
				input.nboxSelectedNonTemporary = world.multis[newI];
				input.nboxSelectedPairs[Math.floor(newI / 2)] = world.multis[newI];

				input.tab(world.multis[newI]);
				input.autoRespawn(world.multis[newI]);
				return true;
			}

			if (settings.nbox) {
				if (!release && keybind === settings.nboxSwitchPair.toLowerCase()) {
					const i = world.multis.indexOf(input.nboxSelectedReal);
					const pair = Math.floor(i / 2);
					// don't allow switching in a pair that doesn't exist
					const partner = pair * 2 === i ? (i + 1 < settings.nboxCount ? i + 1 : i) : i - 1;
					input.nboxSelectedReal = input.nboxSelectedPairs[pair] = world.multis[partner];
					input.nboxSelectedTemporary.clear(); // but still clear the temporary holds regardless
					input.tab(world.multis[partner]);
					input.autoRespawn(world.selected);
					return true;
				}

				if (!release && keybind === settings.nboxCyclePair.toLowerCase()) {
					const i = world.multis.indexOf(input.nboxSelectedReal);
					const pair = Math.floor(i / 2);
					const newPair = (pair + 1) % Math.ceil(settings.nboxCount / 2);
					input.nboxSelectedReal = input.nboxSelectedPairs[newPair];
					input.nboxSelectedTemporary.clear();
					input.tab(input.nboxSelectedReal);
					input.autoRespawn(world.selected);
					return true;
				}

				for (let i = 0; i < settings.nboxCount; ++i) {
					if (!release && keybind === settings.nboxSelectKeybinds[i].toLowerCase()) {
						input.nboxSelectedReal = input.nboxSelectedPairs[Math.floor(i / 2)] = world.multis[i];
						input.nboxSelectedTemporary.clear();
						input.tab(world.multis[i]);
						input.autoRespawn(world.selected);
						return true;
					}

					const hold = settings.nboxHoldKeybinds[i].toLowerCase();
					if (keybind === hold || (release && hold.endsWith('+' + keybind))) {
						if (release) {
							input.nboxSelectedTemporary.delete(world.multis[i]);
							if (input.nboxSelectedTemporary.size === 0) input.tab(input.nboxSelectedReal);
						} else {
							input.nboxSelectedTemporary.add(world.multis[i]);
							input.tab(world.multis[i]);
						}
						input.autoRespawn(world.selected);
						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(view, 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 => {
			if (unfocused()) return;
			handleKeybind(e);
		});
		addEventListener('mouseup', e => {
			if (unfocused()) return;
			handleKeybind(e);
		});

		// prompt before closing window
		addEventListener('beforeunload', e => e.preventDefault());

		// prevent right clicking on the game
		ui.game.canvas.addEventListener('contextmenu', e => e.preventDefault());

		// 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 nickList = () => {
			const target = settings.nbox ? settings.nboxCount : settings.multibox ? 2 : 1;
			for (let i = input.nick.length; i < target; ++i) {
				const el = /** @type {HTMLInputElement} */ (input.nick[0].cloneNode(true));
				el.maxLength = 50;
				el.placeholder = `Nickname #${i + 1}`;
				el.value = settings.multiNames[i - 1] || '';

				el.addEventListener('change', () => {
					settings.multiNames[i - 1] = el.value;
					settings.save();
				});

				const row = /** @type {Element} */ (input.nick[0].parentElement?.cloneNode());
				row.appendChild(el);
				input.nick[input.nick.length - 1].parentElement?.insertAdjacentElement('afterend', row);
				input.nick.push(el);
			}

			for (let i = input.nick.length; i > target; --i) {
				input.nick.pop()?.parentElement?.remove();
			}
		};
		nickList();
		setInterval(nickList, 500);

		/** @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?');

		play.addEventListener('click', () => {
			const con = net.connections.get(world.selected);
			if (!con?.handshake) return;
			ui.toggleEscOverlay(false);
			net.play(world.selected, input.playData(input.name(world.selected), false));
		});
		spectate.addEventListener('click', () => {
			const con = net.connections.get(world.selected);
			if (!con?.handshake) return;
			ui.toggleEscOverlay(false);
			net.play(world.selected, input.playData(input.name(world.selected), 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();

		/**
		 * @param {string} name
		 * @param {number} type
		 * @param {string} source
		 */
		function 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;
		}

		/**
		 * @param {string} name
		 * @param {string} vSource
		 * @param {string} fSource
		 * @param {string[]} ubos
		 * @param {string[]} textures
		 */
		function 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);
			for (let i = 0; i < textures.length; ++i) {
				const loc = gl.getUniformLocation(p, textures[i]);
				gl.uniform1i(loc, i);
			}
			gl.useProgram(null);

			return p;
		}

		const parts = {
			boilerplate: '#version 300 es\nprecision highp float; precision highp int;',
			borderUbo: `layout(std140) uniform Border { // size = 0x28
				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 { // size = 0x10
				float u_camera_ratio; // @ 0x00
				float u_camera_scale; // @ 0x04
				vec2 u_camera_pos; // @ 0x08
			};`,
			cellUbo: `layout(std140) uniform Cell { // size = 0x28
				float u_cell_radius; // @ 0x00, i = 0
				float u_cell_radius_skin; // @ 0x04, i = 1
				vec2 u_cell_pos; // @ 0x08, i = 2
				vec4 u_cell_color; // @ 0x10, i = 4
				float u_cell_alpha; // @ 0x20, i = 8
				int u_cell_flags; // @ 0x24, i = 9
			};`,
			cellSettingsUbo: `layout(std140) uniform CellSettings { // size = 0x40
				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
				float u_cell_active_outline_thickness; // @ 0x40
			};`,
			circleUbo: `layout(std140) uniform Circle { // size = 0x08
				float u_circle_alpha; // @ 0x00
				float u_circle_scale; // @ 0x04
			};`,
			textUbo: `layout(std140) uniform Text { // size = 0x38
				vec4 u_text_color1; // @ 0x00, i = 0
				vec4 u_text_color2; // @ 0x10, i = 4
				float u_text_alpha; // @ 0x20, i = 8
				float u_text_aspect_ratio; // @ 0x24, i = 9
				float u_text_scale; // @ 0x28, i = 10
				int u_text_silhouette_enabled; // @ 0x2c, i = 11
				vec2 u_text_offset; // @ 0x30, i = 12
			};`,
			tracerUbo: `layout(std140) uniform Tracer { // size = 0x10
				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_texture;
				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_texture, 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'], ['u_texture']);



			programs.cell = program('cell', `
				${parts.boilerplate}
				layout(location = 0) in vec2 a_vertex;
				${parts.cameraUbo}
				${parts.cellUbo}
				${parts.cellSettingsUbo}
				flat out vec4 f_active_outline;
				flat out float f_active_radius;
				flat out float f_blur;
				flat out int f_color_under_skin;
				flat out int f_show_skin;
				flat out vec4 f_subtle_outline;
				flat out float f_subtle_radius;
				flat out vec4 f_unsplittable_outline;
				flat out float f_unsplittable_radius;
				out vec2 v_vertex;
				out vec2 v_uv;

				void main() {
					f_blur = 0.5 * u_cell_radius * (540.0 * u_camera_scale);
					f_color_under_skin = u_cell_flags & 0x20;
					f_show_skin = u_cell_flags & 0x01;

					// subtle outlines (at least 1px wide)
					float subtle_thickness = max(max(u_cell_radius * 0.02, 2.0 / (540.0 * u_camera_scale)), 10.0);
					f_subtle_radius = 1.0 - (subtle_thickness / u_cell_radius);
					if ((u_cell_flags & 0x02) != 0) {
						f_subtle_outline = u_cell_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(u_cell_radius * 0.04, 4.0 / (540.0 * u_camera_scale)), 10.0);
					f_unsplittable_radius = 1.0 - (unsplittable_thickness / u_cell_radius);
					if ((u_cell_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(u_cell_radius * 0.06, 6.0 / (540.0 * u_camera_scale)), 10.0);
					f_active_radius = 1.0 - max(active_thickness / u_cell_radius, u_cell_active_outline_thickness);
					if ((u_cell_flags & 0x0c) != 0) {
						f_active_outline = (u_cell_flags & 0x04) != 0 ? u_cell_active_outline : u_cell_inactive_outline;
					} else {
						f_active_outline = vec4(0, 0, 0, 0);
					}

					v_vertex = a_vertex;
					v_uv = a_vertex * min(u_cell_radius / u_cell_radius_skin, 1.0) * 0.5 + 0.5;

					vec2 clip_pos = -u_camera_pos + u_cell_pos + v_vertex * u_cell_radius;
					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_color_under_skin;
				flat in int f_show_skin;
				flat in vec4 f_subtle_outline;
				flat in float f_subtle_radius;
				flat in vec4 f_unsplittable_outline;
				flat in float f_unsplittable_radius;
				in vec2 v_vertex;
				in vec2 v_uv;
				${parts.cameraUbo}
				${parts.cellUbo}
				${parts.cellSettingsUbo}
				uniform sampler2D u_skin;
				out vec4 out_color;

				void main() {
					float d = length(v_vertex.xy);
					if (f_show_skin == 0 || f_color_under_skin != 0) {
						out_color = u_cell_color;
					}

					// skin
					if (f_show_skin != 0) {
						vec4 tex = texture(u_skin, v_uv);
						out_color = out_color * (1.0 - tex.a) + tex;
					}

					// subtle outline
					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
					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
					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;

					// final circle mask
					a = clamp(-f_blur * (d - 1.0), 0.0, 1.0);
					out_color.a *= a * u_cell_alpha;
				}
			`, ['Camera', 'Cell', 'CellSettings'], ['u_skin']);



			// 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.text = program('text', `
				${parts.boilerplate}
				layout(location = 0) in vec2 a_vertex;
				${parts.cameraUbo}
				${parts.cellUbo}
				${parts.textUbo}
				out vec4 v_color;
				out vec2 v_uv;
				out vec2 v_vertex;

				void main() {
					v_uv = a_vertex * 0.5 + 0.5;
					float c2_alpha = (v_uv.x + v_uv.y) / 2.0;
					v_color = u_text_color1 * (1.0 - c2_alpha) + u_text_color2 * c2_alpha;
					v_vertex = a_vertex;

					vec2 clip_space = v_vertex * u_text_scale + u_text_offset;
					clip_space *= u_cell_radius_skin * 0.45 * vec2(u_text_aspect_ratio, 1.0);
					clip_space += -u_camera_pos + u_cell_pos;
					clip_space *= u_camera_scale * vec2(1.0 / u_camera_ratio, -1.0);
					gl_Position = vec4(clip_space, 0, 1);
				}
			`, `
				${parts.boilerplate}
				in vec4 v_color;
				in vec2 v_uv;
				in vec2 v_vertex;
				${parts.cameraUbo}
				${parts.cellUbo}
				${parts.textUbo}
				uniform sampler2D u_texture;
				uniform sampler2D u_silhouette;
				out vec4 out_color;

				float f(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 fv(vec4 v) {
					return vec4(f(v.x), f(v.y), f(v.z), f(v.w));
				}

				void main() {
					vec4 normal = texture(u_texture, v_uv);

					if (u_text_silhouette_enabled != 0) {
						vec4 silhouette = texture(u_silhouette, v_uv);

						// #fff - #000 => color (text)
						// #fff - #fff => #fff (respect emoji)
						// #888 - #888 => #888 (respect emoji)
						// #fff - #888 => #888 + color/2 (blur/antialias)
						out_color = silhouette + fv(normal - silhouette) * v_color;
					} else {
						out_color = fv(normal) * v_color;
					}

					out_color.a *= u_text_alpha;
				}
			`, ['Camera', 'Cell', 'Text'], ['u_texture', 'u_silhouette']);

			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'], []);

			// initialize two VAOs; one for pellets and all other objects (0), one for cell glow only (1)
			glconf.vao = {};
			const squareVAA = () => {
				// square (location = 0), used for all instances
				gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
				gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ -1, -1,   1, -1,   -1, 1,   1, 1 ]), gl.STATIC_DRAW);
				gl.enableVertexAttribArray(0);
				gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
			};
			const circleVAA = () => {
				// circle buffer (each instance is 6 floats or 24 bytes)
				const circleBuffer = /** @type {WebGLBuffer} */ (gl.createBuffer());
				gl.bindBuffer(gl.ARRAY_BUFFER, circleBuffer);
				// a_cell_pos, vec2 (location = 1)
				gl.enableVertexAttribArray(1);
				gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 4 * 7, 0);
				gl.vertexAttribDivisor(1, 1);
				// a_cell_radius, float (location = 2)
				gl.enableVertexAttribArray(2);
				gl.vertexAttribPointer(2, 1, gl.FLOAT, false, 4 * 7, 4 * 2);
				gl.vertexAttribDivisor(2, 1);
				// a_cell_color, vec3 (location = 3)
				gl.enableVertexAttribArray(3);
				gl.vertexAttribPointer(3, 4, gl.FLOAT, false, 4 * 7, 4 * 3);
				gl.vertexAttribDivisor(3, 1);

				return circleBuffer;
			};
			const alphaVAA = () => {
				// circle alpha buffer, updated every frame
				const alphaBuffer = /** @type {WebGLBuffer} */ (gl.createBuffer());
				gl.bindBuffer(gl.ARRAY_BUFFER, alphaBuffer);
				// a_cell_alpha, float (location = 4)
				gl.enableVertexAttribArray(4);
				gl.vertexAttribPointer(4, 1, gl.FLOAT, false, 0, 0);
				gl.vertexAttribDivisor(4, 1);

				return alphaBuffer;
			};
			const lineVAA = () => {
				// circle alpha buffer, updated every frame
				const lineBuffer = /** @type {WebGLBuffer} */ (gl.createBuffer());
				gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer);
				// a_pos1, vec2 (location = 1)
				gl.enableVertexAttribArray(1);
				gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 4 * 4, 0);
				gl.vertexAttribDivisor(1, 1);
				// a_pos2, vec2 (location = 2)
				gl.enableVertexAttribArray(2);
				gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 4 * 4, 4 * 2);
				gl.vertexAttribDivisor(2, 1);

				return lineBuffer;
			};

			// main vao
			{
				const vao = /** @type {WebGLVertexArrayObject} */ (gl.createVertexArray());
				gl.bindVertexArray(vao);
				squareVAA();
				glconf.vao.main = { vao };
			}
			// cell glow vao
			{
				const vao = /** @type {WebGLVertexArrayObject} */ (gl.createVertexArray());
				gl.bindVertexArray(vao);
				squareVAA();
				glconf.vao.cell = { vao, circle: circleVAA(), alpha: alphaVAA() };
			}
			// pellet + pellet glow vao
			{
				const vao = /** @type {WebGLVertexArrayObject} */ (gl.createVertexArray());
				gl.bindVertexArray(vao);
				squareVAA();
				glconf.vao.pellet = { vao, circle: circleVAA(), alpha: alphaVAA() };
			}
			// tracer vao
			{
				const vao = /** @type {WebGLVertexArrayObject} */ (gl.createVertexArray());
				gl.bindVertexArray(vao);
				squareVAA();
				glconf.vao.tracer = { vao, line: lineVAA() };
			}
		};

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



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

		// #1 : define small misc objects
		// 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 lastMinimapDraw = performance.now();
		/** @type {{ bg: ImageData, darkTheme: boolean } | undefined} */
		let minimapCache;
		document.fonts.addEventListener('loadingdone', () => void (minimapCache = undefined));


		// #2 : define caching functions
		const { resetDatabaseCache, resetTextureCache, textureFromCache, textureFromDatabase } = (() => {
			/** @type {Map<string, {
			 * 	color: [number, number, number, number], texture: WebGLTexture, width: number, height: number
			 * } | null>} */
			const cache = new Map();
			render.textureCache = cache;

			/** @type {Map<string, {
			 * 	color: [number, number, number, number], texture: WebGLTexture, width: number, height: number
			 * } | null>} */
			const dbCache = new Map();
			render.dbCache = dbCache;

			return {
				resetTextureCache: () => cache.clear(),
				/**
				 * @param {string} src
				 * @returns {{
				 * 	color: [number, number, number, number], texture: WebGLTexture, width: number, height: number
				 * } | undefined}
				 */
				textureFromCache: src => {
					const cached = cache.get(src);
					if (cached !== undefined)
						return cached ?? undefined;

					cache.set(src, null);

					const image = new Image();
					image.crossOrigin = '';
					image.addEventListener('load', () => {
						const texture = /** @type {WebGLTexture} */ (gl.createTexture());
						if (!texture) return;

						gl.bindTexture(gl.TEXTURE_2D, texture);
						gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
						gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
						gl.generateMipmap(gl.TEXTURE_2D);

						const color = aux.dominantColor(image);
						cache.set(src, { color, texture, width: image.width, height: image.height });
					});
					image.src = src;

					return undefined;
				},
				resetDatabaseCache: () => dbCache.clear(),
				/**
				 * @param {string} key
				 * @returns {{
				 * 	color: [number, number, number, number], texture: WebGLTexture, width: number, height: number
				 * } | undefined}
				 */
				textureFromDatabase: key => {
					const cached = dbCache.get(key);
					if (cached !== undefined)
						return cached ?? undefined;

					/** @type {IDBDatabase | undefined} */
					const database = settings.database;
					if (!database) return undefined;

					dbCache.set(key, null);
					const req = database.transaction('images').objectStore('images').get(key);
					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', () => {
								const texture = gl.createTexture();
								if (!texture) return;

								gl.bindTexture(gl.TEXTURE_2D, texture);
								gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
								gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
								gl.generateMipmap(gl.TEXTURE_2D);

								const color = aux.dominantColor(image);
								dbCache.set(key, { color, texture, width: image.width, height: image.height });
							});
							image.src = /** @type {string} */ (reader.result);
						});
						reader.readAsDataURL(req.result);
					});
					req.addEventListener('error', err => {
						console.warn(`sigfix database failed to get ${key}:`, err);
					});
				},
			};
		})();
		render.resetDatabaseCache = resetDatabaseCache;
		render.resetTextureCache = resetTextureCache;

		const { maxMassWidth, refreshTextCache, massTextFromCache, resetTextCache, textFromCache } = (() => {
			/**
			 * @template {boolean} T
			 * @typedef {{
			 * 	aspectRatio: number,
			 * 	text: WebGLTexture | null,
			 *	silhouette: WebGLTexture | null | undefined,
			 * 	accessed: number
			 * }} CacheEntry
			 */
			/** @type {Map<string, CacheEntry<boolean>>} */
			const cache = new Map();
			render.textCache = cache;

			setInterval(() => {
				// remove text after not being used for 1 minute
				const now = performance.now();
				cache.forEach((entry, text) => {
					if (now - entry.accessed > 60_000) {
						// immediately delete text instead of waiting for GC
						if (entry.text !== undefined)
							gl.deleteTexture(entry.text);
						if (entry.silhouette !== undefined)
							gl.deleteTexture(entry.silhouette);
						cache.delete(text);
					}
				});
			}, 60_000);

			const canvas = document.createElement('canvas');
			const ctx = aux.require(
				canvas.getContext('2d', { willReadFrequently: true }),
				'Unable to get 2D context for text drawing. This is probably your browser being weird, maybe reload ' +
				'the page?',
			);

			// sigmod forces a *really* ugly shadow on ctx.fillText so we have to lock the property beforehand
			const realProps = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(ctx));
			const realShadowBlurSet
				= aux.require(realProps.shadowBlur.set, 'did CanvasRenderingContext2D spec change?').bind(ctx);
			const realShadowColorSet
				= aux.require(realProps.shadowColor.set, 'did CanvasRenderingContext2D spec change?').bind(ctx);
			Object.defineProperties(ctx, {
				shadowBlur: {
					get: () => 0,
					set: x => {
						if (x === 0) realShadowBlurSet(0);
						else realShadowBlurSet(8);
					},
				},
				shadowColor: {
					get: () => 'transparent',
					set: x => {
						if (x === 'transparent') realShadowColorSet('transparent');
						else realShadowColorSet('#0003');
					},
				},
			});

			/**
			 * @param {string} text
			 * @param {boolean} silhouette
			 * @param {boolean} mass
			 * @returns {WebGLTexture | null}
			 */
			const texture = (text, silhouette, mass) => {
				const texture = gl.createTexture();
				if (!texture) return texture;

				const baseTextSize = 96;
				const textSize = baseTextSize * (mass ? 0.5 * settings.massScaleFactor : settings.nameScaleFactor);
				const lineWidth = Math.ceil(textSize / 10) * settings.textOutlinesFactor;

				let font = '';
				if (mass ? settings.massBold : settings.nameBold)
					font = 'bold';
				font += ` ${textSize}px "${sigmod.settings.font || 'Ubuntu'}", Ubuntu`;

				ctx.font = font;
				// if rendering an empty string (somehow) then width can be 0 with no outlines
				canvas.width = (ctx.measureText(text).width + lineWidth * 4) || 1;
				canvas.height = textSize * 3;
				ctx.clearRect(0, 0, canvas.width, canvas.height);

				// setting canvas.width resets the canvas state
				ctx.font = font;
				ctx.lineJoin = 'round';
				ctx.lineWidth = lineWidth;
				ctx.fillStyle = silhouette ? '#000' : '#fff';
				ctx.strokeStyle = '#000';
				ctx.textBaseline = 'middle';

				ctx.shadowBlur = lineWidth;
				ctx.shadowColor = lineWidth > 0 ? '#0002' : 'transparent';

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

				const data = ctx.getImageData(0, 0, canvas.width, canvas.height);

				gl.bindTexture(gl.TEXTURE_2D, texture);
				gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, data);
				gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
				gl.generateMipmap(gl.TEXTURE_2D);
				return texture;
			};

			let maxMassWidth = 0;
			/** @type {({ height: number, width: number, texture: WebGLTexture | null } | undefined)[]} */
			const massTextCache = [];

			/**
			 * @param {string} digit
			 * @returns {{ height: number, width: number, texture: WebGLTexture | null }}
			 */
			const massTextFromCache = digit => {
				let cached = massTextCache[/** @type {any} */ (digit)];
				if (!cached) {
					cached = massTextCache[digit] = {
						texture: texture(digit, false, true),
						height: canvas.height, // mind the execution order
						width: canvas.width,
					};
					if (cached.width > maxMassWidth) maxMassWidth = cached.width;
				}

				return cached;
			};

			const resetTextCache = () => {
				cache.clear();
				maxMassWidth = 0;
				while (massTextCache.length > 0) massTextCache.pop();
			};

			/** @type {{
			 * 	massBold: boolean, massScaleFactor: number, nameBold: boolean, nameScaleFactor: number,
			 * 	outlinesFactor: number, font: string | undefined,
			 * } | undefined} */
			let drawn;

			const refreshTextCache = () => {
				if (!drawn ||
					(drawn.massBold !== settings.massBold || drawn.massScaleFactor !== settings.massScaleFactor
						|| drawn.nameBold !== settings.nameBold || drawn.nameScaleFactor !== settings.nameScaleFactor
						|| drawn.outlinesFactor !== settings.textOutlinesFactor || drawn.font !== sigmod.settings.font)
				) {
					resetTextCache();
					drawn = {
						massBold: settings.massBold, massScaleFactor: settings.massScaleFactor,
						nameBold: settings.nameBold, nameScaleFactor: settings.nameScaleFactor,
						outlinesFactor: settings.textOutlinesFactor, font: sigmod.settings.font,
					};
				}
			};

			/**
			 * @template {boolean} T
			 * @param {string} text
			 * @param {T} silhouette
			 * @returns {CacheEntry<T>}
			 */
			const textFromCache = (text, silhouette) => {
				let entry = cache.get(text);
				if (!entry) {
					const shortened = aux.trim(text);
					/** @type {CacheEntry<T>} */
					entry = {
						text: texture(shortened, false, false),
						aspectRatio: canvas.width / canvas.height, // mind the execution order
						silhouette: silhouette ? texture(shortened, true, false) : undefined,
						accessed: performance.now(),
					};
					cache.set(text, entry);
				} else {
					entry.accessed = performance.now();
				}

				if (silhouette && entry.silhouette === undefined) {
					setTimeout(() => {
						entry.silhouette = texture(aux.trim(text), true, false);
					});
				}

				return entry;
			};

			// reload text once Ubuntu has loaded, prevents some serif fonts from being locked in
			// also support loading in new fonts at any time via sigmod
			document.fonts.addEventListener('loadingdone', () => resetTextCache());

			return {
				maxMassWidth: () => maxMassWidth,
				massTextFromCache,
				refreshTextCache,
				resetTextCache,
				textFromCache,
			};
		})();
		render.resetTextCache = resetTextCache;
		render.textFromCache = textFromCache;

		// #3 : define other render functions
		/**
		 * @param {CellFrame} frame
		 * @param {number} now
		 */
		render.alpha = (frame, now) => {
			// TODO: lots of opportunity here to make the game look nice (like delta)
			// note that 0 drawDelay is supported here
			let alpha = (now - frame.born) / settings.drawDelay;
			if (frame.deadAt !== undefined) {
				alpha = Math.min(alpha, 1 - (now - frame.deadAt) / settings.drawDelay);
			}

			return Math.min(Math.max(alpha, 0), 1);
		};

		/**
		 * @param {Cell} cell
		 */
		render.skin = cell => {
			/** @type {CellDescription} */
			const desc = world.synchronized ? cell.views.values().next().value : cell.views.get(world.selected);
			if (!desc) return undefined;

			let ownerView;
			for (const [view, vision] of world.views) {
				if (vision.owned.has(cell.id)) {
					ownerView = view;
					break;
				}
			}

			// 🖼️
			let texture;
			if (ownerView) {
				const index = world.multis.indexOf(ownerView);
				if (index >= 2) {
					if (settings.selfSkinNbox[index]) {
						if (settings.selfSkinNbox[index].startsWith('🖼️')) texture = textureFromDatabase(`selfSkinNbox.${index}`);
						else texture = textureFromCache(settings.selfSkinNbox[index]);
					}
				} else {
					const prop = ownerView === world.viewId.secondary ? 'selfSkinMulti' : 'selfSkin';
					if (settings[prop]) {
						if (settings[prop].startsWith('🖼️')) texture = textureFromDatabase(prop);
						else texture = textureFromCache(settings[prop]);
					}
				}
			}

			// allow turning off sigmally skins while still using custom skins
			if (!texture && aux.settings.showSkins && desc.skin) {
				texture = textureFromCache(desc.skin);
			}

			return texture;
		};

		const circleBuffers = {
			cell: new Float32Array(0),
			cellAlpha: new Float32Array(0),
			/** @type {object | undefined} */
			lastCellVao: undefined,
			pellet: new Float32Array(0),
			pelletAlpha: new Float32Array(0),
			pelletsUploaded: 0,
			/** @type {object | undefined} */
			lastPelletVao: undefined,
		};
		/** @param {boolean} pellets */
		render.upload = pellets => {
			const now = performance.now();
			if (now - render.lastFrame > 1000) {
				// do not render pellets on inactive windows (very laggy!)
				circleBuffers.pelletsUploaded = 0;
				return;
			}

			let uploading = 0;
			for (const cell of world[pellets ? 'pellets' : 'cells'].values()) {
				const frame = world.synchronized ? cell.merged : cell.views.get(world.selected)?.frames[0];
				if (!frame) continue;
				if (pellets) {
					if (frame.deadTo === -1) ++uploading;
				} else {
					/** @type {CellDescription} */
					const desc = world.synchronized ? cell.views.values().next().value : cell.views.get(world.selected);
					if (!desc) continue; // shouldn't happen
					if (!desc.jagged) ++uploading; // don't make viruses glow
				}
			}

			let alpha = circleBuffers[pellets ? 'pelletAlpha' : 'cellAlpha'];
			let instances = circleBuffers[pellets ? 'pellet' : 'cell'];
			const vao = glconf.vao[pellets ? 'pellet' : 'cell'];

			// resize buffers as necessary
			let capacity = alpha.length || 1;
			let resizing = circleBuffers[pellets ? 'lastPelletVao' : 'lastCellVao'] !== vao;
			while (capacity < uploading) {
				capacity *= 2;
				resizing = true;
			}
			if (resizing) {
				alpha = circleBuffers[pellets ? 'pelletAlpha' : 'cellAlpha'] = new Float32Array(capacity);
				instances = circleBuffers[pellets ? 'pellet' : 'cell'] = new Float32Array(capacity * 7);
			}

			const override = pellets ? sigmod.settings.foodColor : sigmod.settings.cellColor;
			let i = 0;
			for (const cell of world[pellets ? 'pellets' : 'cells'].values()) {
				const frame = world.synchronized ? cell.merged : cell.views.get(world.selected)?.frames[0];
				if (!frame || (pellets && frame.deadTo !== -1)) continue;

				let x, y, r;
				if (pellets) {
					({ nx: x, ny: y, nr: r } = frame);
				} else {
					const interp = world.synchronized ? cell.merged : cell.views.get(world.selected);
					if (!interp) continue; // should never happen (ts-check)

					/** @type {CellFrame | undefined} */
					let killerFrame;
					/** @type {CellInterpolation | undefined} */
					let killerInterp;
					let killer = frame.deadTo !== -1 && world.cells.get(frame.deadTo);
					if (killer) {
						killerFrame = world.synchronized ? killer.merged : killer.views.get(world.selected)?.frames[0];
						killerInterp = world.synchronized ? killer.merged : killer.views.get(world.selected);
					}

					({ x, y, r } = world.xyr(frame, interp, killerFrame, killerInterp, false, now));
				}
				instances[i * 7] = x;
				instances[i * 7 + 1] = y;
				instances[i * 7 + 2] = r;

				/** @type {CellDescription} */
				const desc = world.synchronized ? cell.views.values().next().value : cell.views.get(world.selected);
				if (!desc || desc.jagged) continue;

				/** @type {[number, number, number] | [number, number, number, number]} */
				let color = desc.rgb;
				if (pellets) {
					if (override?.[0] === 0 && override?.[1] === 0 && override?.[2] === 0) {
						color = [color[0], color[1], color[2], override[3]];
					} else if (override) {
						color = override;
					}
				} else {
					if (override) color = override;
					const skin = render.skin(cell);
					if (skin) {
						// blend with player color
						if (settings.colorUnderSkin) {
							color = [
								color[0] + (skin.color[0] - color[0]) * skin.color[3],
								color[1] + (skin.color[1] - color[1]) * skin.color[3],
								color[2] + (skin.color[2] - color[2]) * skin.color[3],
								1
							];
						} else {
							color = [skin.color[0], skin.color[1], skin.color[2], 1];
						}
					}
				}
				instances[i * 7 + 3] = color[0];
				instances[i * 7 + 4] = color[1];
				instances[i * 7 + 5] = color[2];
				instances[i * 7 + 6] = color[3] ?? 1;

				++i;
			}

			// now, upload data
			if (resizing) {
				gl.bindBuffer(gl.ARRAY_BUFFER, vao.alpha);
				gl.bufferData(gl.ARRAY_BUFFER, alpha.byteLength, gl.STATIC_DRAW);
				gl.bindBuffer(gl.ARRAY_BUFFER, vao.circle);
				gl.bufferData(gl.ARRAY_BUFFER, instances, gl.STATIC_DRAW);
			} else {
				gl.bindBuffer(gl.ARRAY_BUFFER, vao.circle);
				gl.bufferSubData(gl.ARRAY_BUFFER, 0, instances);
			}
			gl.bindBuffer(gl.ARRAY_BUFFER, null);

			if (pellets) circleBuffers.pelletsUploaded = uploading;
			circleBuffers[pellets ? 'lastPelletVao' : 'lastCellVao'] = vao;
		};

		let tracerFloats = new Float32Array(0);

		// #4 : define ubo views
		// 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.Cell);
		const cellUboBuffer = new ArrayBuffer(gl.getBufferParameter(gl.UNIFORM_BUFFER, gl.BUFFER_SIZE));
		const cellUboFloats = new Float32Array(cellUboBuffer);
		const cellUboInts = new Int32Array(cellUboBuffer);

		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.Text);
		const textUboBuffer = new ArrayBuffer(gl.getBufferParameter(gl.UNIFORM_BUFFER, gl.BUFFER_SIZE));
		const textUboFloats = new Float32Array(textUboBuffer);
		const textUboInts = new Int32Array(textUboBuffer);

		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!


		// #5 : define the render function
		const start = performance.now();
		render.fps = 0;
		render.lastFrame = performance.now();
		const renderGame = () => {
			const 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()) {
				requestAnimationFrame(renderGame);
				return;
			}

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

			refreshTextCache();

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

			// 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.CellSettings);
				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
					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.vao.main.vao);

				let texture;
				if (settings.background) {
					if (settings.background.startsWith('🖼️'))
						texture = textureFromDatabase('background');
					else
						texture = textureFromCache(settings.background);
				} else if (aux.settings.showGrid) {
					texture = textureFromCache(aux.settings.darkTheme ? darkGridSrc : lightGridSrc);
				}
				gl.bindTexture(gl.TEXTURE_2D, texture?.texture ?? null);
				const repeating = texture && texture.width <= 512 && texture.height <= 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 };

				// u_border_color
				borderUboFloats[0] = borderColor[0]; borderUboFloats[1] = borderColor[1];
				borderUboFloats[2] = borderColor[2]; borderUboFloats[3] = borderColor[3];
				// u_border_xyzw_lrtb
				borderUboFloats[4] = borderLrtb.l;
				borderUboFloats[5] = borderLrtb.r;
				borderUboFloats[6] = borderLrtb.t;
				borderUboFloats[7] = borderLrtb.b;

				// flags
				borderUboInts[8] = (texture ? 0x01 : 0) | (aux.settings.darkTheme ? 0x02 : 0) | (repeating ? 0x04 : 0)
					| (settings.rainbowBorder ? 0x08 : 0);

				// u_background_width and u_background_height
				borderUboFloats[9] = texture?.width ?? 1;
				borderUboFloats[10] = texture?.height ?? 1;
				borderUboFloats[11] = (now - start) / 1000 * 0.2 % (Math.PI * 2);

				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);
			})();

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

				// for white cell outlines
				let nextCellIdx = 0;
				const ownedArray = Array.from(vision.owned);
				/** @type {(CellFrame | undefined)[]} */
				const ownedToFrame = ownedArray.map(id => {
					const cell = world.cells.get(id);
					return world.synchronized ? cell?.merged : cell?.views.get(world.selected)?.frames[0];
				});
				for (const cell of ownedToFrame) {
					if (cell && cell.deadAt === undefined) ++nextCellIdx;
				}
				const canSplit = ownedToFrame.map(cell => {
					if (!cell || cell.nr < 128) return false;
					return nextCellIdx++ < 16;
				});

				/**
				 * @param {Cell} cell
				 * @param {boolean} pellet
				 */
				const draw = (cell, pellet) => {
					// #1 : draw cell
					/** @type {CellFrame | undefined} */
					const frame = world.synchronized ? cell.merged : cell.views.get(world.selected)?.frames[0];
					/** @type {CellInterpolation | undefined} */
					const interp = world.synchronized ? cell.merged : cell.views.get(world.selected);
					if (!frame || !interp) return;

					/** @type {CellDescription | undefined} */
					const desc = world.synchronized ? cell.views.values().next().value : cell.views.get(world.selected);
					if (!desc) return;

					/** @type {CellFrame | undefined} */
					let killerFrame;
					/** @type {CellInterpolation | undefined} */
					let killerInterp;
					let killer = frame.deadTo !== -1 && world.cells.get(frame.deadTo);
					if (killer) {
						killerFrame = world.synchronized ? killer.merged : killer.views.get(world.selected)?.frames[0];
						killerInterp = world.synchronized ? killer.merged : killer.views.get(world.selected);
					}

					gl.useProgram(glconf.programs.cell);

					const alpha = render.alpha(frame, now);
					cellUboFloats[8] = alpha * settings.cellOpacity;

					const { x, y, r, jr } = world.xyr(frame, interp, killerFrame, killerInterp, pellet, now);
					// 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)
					cellUboFloats[2] = x;
					cellUboFloats[3] = y;
					if (aux.settings.jellyPhysics && !desc.jagged && !pellet) {
						const strokeThickness = Math.max(jr * 0.01, 10);
						cellUboFloats[0] = jr + strokeThickness;
						cellUboFloats[1] = (settings.jellySkinLag ? r : jr) + strokeThickness;
					} else {
						cellUboFloats[0] = cellUboFloats[1] = r + 2;
					}

					if (desc.jagged) {
						const virusTexture = textureFromCache(virusSrc);
						if (virusTexture) {
							gl.bindTexture(gl.TEXTURE_2D, virusTexture.texture);
							cellUboInts[9] = 0x01; // skin and nothing else
							// draw a fully transparent cell
							cellUboFloats[4] = cellUboFloats[5] = cellUboFloats[6] = cellUboFloats[7] = 0;
						} else {
							cellUboInts[9] = 0;
							// #ff000080 if the virus texture doesn't load
							cellUboFloats[4] = 1;
							cellUboFloats[5] = 0;
							cellUboFloats[6] = 0;
							cellUboFloats[7] = 0.5;
						}

						gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Cell);
						gl.bufferSubData(gl.UNIFORM_BUFFER, 0, cellUboBuffer);
						gl.bindBuffer(gl.UNIFORM_BUFFER, null);
						if (!aux.settings.darkTheme && virusSrc === defaultVirusSrc) {
							// draw default viruses twice as strong for better contrast against light theme
							cellUboFloats[8] = alpha * settings.cellOpacity;
						}
						gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
						return;
					}

					cellUboInts[9] = 0;

					/** @type {[number, number, number, number] | [number, number, number] | undefined} */
					let color = pellet ? foodColor : cellColor;
					if (pellet && foodColor && foodColor[0] === 0 && foodColor[1] === 0 && foodColor[2] === 0) {
						color = [desc.rgb[0], desc.rgb[1], desc.rgb[2], foodColor[3]];
					} else {
						color ??= desc.rgb;
					}

					cellUboFloats[4] = color[0]; cellUboFloats[5] = color[1];
					cellUboFloats[6] = color[2]; cellUboFloats[7] = color[3] ?? 1;

					cellUboInts[9] |= settings.cellOutlines ? 0x02 : 0;
					cellUboInts[9] |= settings.colorUnderSkin ? 0x20 : 0;

					if (!pellet) {
						/** @type {symbol | undefined} */
						let ownerView;
						let ownerVision;
						for (const [otherView, otherVision] of world.views) {
							if (!otherVision.owned.has(cell.id)) continue;
							ownerView = otherView;
							ownerVision = otherVision;
							break;
						}

						if (ownerView === world.selected) {
							const myIndex = ownedArray.indexOf(cell.id);
							if (!canSplit[myIndex]) cellUboInts[9] |= 0x10;

							if (vision.camera.merged) cellUboInts[9] |= 0x04;
						} else if (ownerVision) {
							if (ownerVision.camera.merged) cellUboInts[9] |= 0x08;
						}

						const texture = render.skin(cell);
						if (texture) {
							cellUboInts[9] |= 0x01; // skin
							gl.bindTexture(gl.TEXTURE_2D, texture.texture);
						}
					}

					gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Cell);
					gl.bufferSubData(gl.UNIFORM_BUFFER, 0, cellUboBuffer);
					gl.bindBuffer(gl.UNIFORM_BUFFER, null);
					gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

					// #2 : draw text
					if (pellet) return;
					const name = desc.name || 'An unnamed cell';
					const showThisName = (showNames ?? true) && frame.nr >= 64;
					const showThisMass = aux.settings.showMass && frame.nr >= 64;
					const clan = (settings.clans && aux.clans.get(desc.clan)) || '';
					if (!showThisName && !showThisMass && !clan) return;

					gl.useProgram(glconf.programs.text);
					textUboFloats[8] = alpha; // text_alpha

					let useSilhouette = false;
					if (desc.sub) {
						// text_color1 = #eb9500 * 1.2
						textUboFloats[0] = 0xeb / 255 * 1.2; textUboFloats[1] = 0x95 / 255 * 1.2;
						textUboFloats[2] = 0x00 / 255 * 1.2; textUboFloats[3] = 1;
						// text_color2 = #f9bf0d * 1.2
						textUboFloats[4] = 0xf9 / 255 * 1.2; textUboFloats[5] = 0xbf / 255 * 1.2;
						textUboFloats[6] = 0x0d / 255 * 1.2; textUboFloats[7] = 1;
						useSilhouette = true;
					} else {
						// text_color1 = text_color2 = #fff
						textUboFloats[0] = textUboFloats[1] = textUboFloats[2] = textUboFloats[3] = 1;
						textUboFloats[4] = textUboFloats[5] = textUboFloats[6] = textUboFloats[7] = 1;
					}

					if (input.nick.find(el => el.value === name)) {
						const { nameColor1, nameColor2 } = sigmod.settings;
						if (nameColor1) {
							textUboFloats[0] = nameColor1[0]; textUboFloats[1] = nameColor1[1];
							textUboFloats[2] = nameColor1[2]; textUboFloats[3] = nameColor1[3];
							useSilhouette = true;
						}

						if (nameColor2) {
							textUboFloats[4] = nameColor2[0]; textUboFloats[5] = nameColor2[1];
							textUboFloats[6] = nameColor2[2]; textUboFloats[7] = nameColor2[3];
							useSilhouette = true;
						}
					}

					if (clan) {
						const { aspectRatio, text, silhouette } = textFromCache(clan, useSilhouette);
						if (text) {
							textUboFloats[9] = aspectRatio; // text_aspect_ratio
							textUboFloats[10]
								= showThisName ? settings.clanScaleFactor * 0.5 : settings.nameScaleFactor;
							textUboInts[11] = Number(useSilhouette); // text_silhouette_enabled
							textUboFloats[12] = 0; // text_offset.x
							textUboFloats[13] = showThisName
								? -settings.nameScaleFactor/3 - settings.clanScaleFactor/6 : 0; // text_offset.y

							gl.bindTexture(gl.TEXTURE_2D, text);
							if (silhouette) {
								gl.activeTexture(gl.TEXTURE1);
								gl.bindTexture(gl.TEXTURE_2D, silhouette);
								gl.activeTexture(gl.TEXTURE0);
							}

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

					if (showThisName) {
						const { aspectRatio, text, silhouette } = textFromCache(name, useSilhouette);
						if (text) {
							textUboFloats[9] = aspectRatio; // text_aspect_ratio
							textUboFloats[10] = settings.nameScaleFactor; // text_scale
							textUboInts[11] = Number(useSilhouette); // text_silhouette_enabled
							textUboFloats[12] = textUboFloats[13] = 0; // text_offset = (0, 0)

							gl.bindTexture(gl.TEXTURE_2D, text);
							if (silhouette) {
								gl.activeTexture(gl.TEXTURE1);
								gl.bindTexture(gl.TEXTURE_2D, silhouette);
								gl.activeTexture(gl.TEXTURE0);
							}

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

					if (showThisMass) {
						textUboFloats[8] = alpha * settings.massOpacity; // text_alpha
						textUboFloats[10] = 0.5 * settings.massScaleFactor; // text_scale
						textUboInts[11] = 0; // text_silhouette_enabled

						let yOffset;
						if (showThisName)
							yOffset = (settings.nameScaleFactor + 0.5 * settings.massScaleFactor) / 3;
						else if (clan)
							yOffset = (1 + 0.5 * settings.massScaleFactor) / 3;
						else
							yOffset = 0;
						// draw each digit separately, as Ubuntu makes them all the same width.
						// significantly reduces the size of the text cache
						const mass = Math.floor(frame.nr * frame.nr / 100).toString();
						const maxWidth = maxMassWidth();
						for (let i = 0; i < mass.length; ++i) {
							const { height, width, texture } = massTextFromCache(mass[i]);
							textUboFloats[9] = width / height; // text_aspect_ratio
							// text_offset.x; kerning is fixed by subtracting most of the padding from lineWidth
							textUboFloats[12] = (i - (mass.length - 1) / 2) * settings.massScaleFactor
								* (maxWidth / width)
								* (maxWidth - 20 * settings.textOutlinesFactor * settings.massScaleFactor) / maxWidth;
							textUboFloats[13] = yOffset;
							gl.bindTexture(gl.TEXTURE_2D, texture);

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

				// draw pellets
				{
					// blend function for all pellets
					if (settings.pelletGlow && aux.settings.darkTheme) gl.blendFunc(gl.SRC_ALPHA, gl.ONE);

					// draw unanimated pellets using instanced drawing
					gl.useProgram(glconf.programs.circle);
					gl.bindVertexArray(glconf.vao.pellet.vao);
					let i = 0;
					for (const cell of world.pellets.values()) {
						const frame = world.synchronized ? cell.merged : cell.views.get(world.selected)?.frames[0];
						if (frame?.deadTo !== -1) continue;
						circleBuffers.pelletAlpha[i++] = render.alpha(frame, now);
					}
					gl.bindBuffer(gl.ARRAY_BUFFER, glconf.vao.pellet.alpha);
					gl.bufferSubData(gl.ARRAY_BUFFER, 0, circleBuffers.pelletAlpha);
					gl.bindBuffer(gl.ARRAY_BUFFER, null);

					gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Circle);
					circleUboFloats[0] = 1; // alpha
					circleUboFloats[1] = 0; // scale (0 means no blur)
					gl.bufferSubData(gl.UNIFORM_BUFFER, 0, circleUboFloats);
					gl.bindBuffer(gl.UNIFORM_BUFFER, null);
					// pellet data is not uploaded if tab is closed for a while, so pelletsUploaded would be 0
					gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, circleBuffers.pelletsUploaded);

					if (settings.pelletGlow) {
						// draw unanimated pellet glow
						gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
						circleUboFloats[0] = 0.25; // alpha
						circleUboFloats[1] = 2; // scale
						gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Circle);
						gl.bufferData(gl.UNIFORM_BUFFER, circleUboFloats, gl.STATIC_DRAW);
						gl.bindBuffer(gl.UNIFORM_BUFFER, null);
						gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, i);

						// reset blend func
						gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
					}

					// draw animated pellets without instanced drawing
					gl.bindVertexArray(glconf.vao.main.vao);
					for (const cell of world.pellets.values()) {
						const frame = world.synchronized ? cell.merged : cell.views.get(world.selected)?.frames[0];
						if (frame && frame.deadTo !== -1) draw(cell, true);
						// do not make eaten pellets glow
					}
				}

				// draw cells
				{
					/** @type {[Cell, number][]} */
					const sorted = [];
					for (const cell of world.cells.values()) {
						const frame = world.synchronized ? cell.merged : cell.views.get(world.selected)?.frames[0];
						const interp = world.synchronized ? cell.merged : cell.views.get(world.selected);
						if (!frame || !interp) continue;

						const a = Math.min(Math.max((now - interp.updated) / settings.drawDelay, 0), 1);
						const computedR = interp.or + (frame.nr - interp.or) * a;
						sorted.push([cell, computedR]);
					}

					gl.bindVertexArray(glconf.vao.main.vao);
					sorted.sort(([_a, ar], [_b, br]) => ar - br);
					for (const [cell] of sorted) draw(cell, false);

					// draw glow *after* all cells (so the glow goes above the text)
					if (settings.cellGlow) {
						render.upload(false);
						let i = 0;
						for (const cell of world.cells.values()) {
							const frame = world.synchronized ? cell.merged : cell.views.get(world.selected)?.frames[0];
							/** @type {CellDescription} */
							const desc = world.synchronized ? cell.views.values().next().value : cell.views.get(world.selected);
							if (!frame || !desc || desc.jagged) continue;

							let alpha = render.alpha(frame, now);
							// it looks kinda weird when cells get sucked in when being eaten
							if (frame.deadTo !== -1) alpha *= 0.25;
							circleBuffers.cellAlpha[i++] = alpha;
						}

						gl.useProgram(glconf.programs.circle);
						gl.bindVertexArray(glconf.vao.cell.vao);
						gl.blendFunc(gl.SRC_ALPHA, gl.ONE);

						gl.bindBuffer(gl.ARRAY_BUFFER, glconf.vao.cell.alpha);
						gl.bufferSubData(gl.ARRAY_BUFFER, 0, circleBuffers.cellAlpha);
						gl.bindBuffer(gl.ARRAY_BUFFER, null);

						circleUboFloats[0] = 0.25; // alpha
						// 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.bufferData(gl.UNIFORM_BUFFER, circleUboFloats, gl.STATIC_DRAW);
						gl.bindBuffer(gl.UNIFORM_BUFFER, null);
						gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, i);

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

				// draw tracers
				if (settings.tracer) {
					gl.useProgram(glconf.programs.tracer);
					gl.bindVertexArray(glconf.vao.tracer.vao);

					tracerUboFloats[0] = 0.5; // #7f7f7f color
					tracerUboFloats[1] = 0.5;
					tracerUboFloats[2] = 0.5;
					tracerUboFloats[3] = 0.5;
					tracerUboFloats[4] = 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; // tracers are the last step in cells(), so a return is OK

					// 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);

					const camera
						= world.singleCamera(world.selected, vision, settings.camera !== 'default' ? 2 : 0, now);

					let i = 0;
					for (const id of vision.owned) {
						const cell = world.cells.get(id);
						const frame = world.synchronized ? cell?.merged : cell?.views.get(world.selected)?.frames[0];
						const interp = world.synchronized ? cell?.merged : cell?.views.get(world.selected);
						if (!frame || !interp || frame.deadAt !== undefined) continue;

						const { x, y } = world.xyr(frame, interp, undefined, undefined, false, 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 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.vao.tracer.line);
					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) {
					ui.minimap.canvas.style.display = 'none';
					return;
				} 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
				ctx.fillStyle = settings.theme[3] ? aux.rgba2hex6(...settings.theme) : '#ff0';
				ctx.globalAlpha = 0.3;

				const sectionX = Math.floor((vision.camera.x - border.l) / gameWidth * 5);
				const sectionY = Math.floor((vision.camera.y - border.t) / gameHeight * 5);
				ctx.fillRect(sectionX * sectorSize, sectionY * sectorSize, sectorSize, sectorSize);

				ctx.globalAlpha = 1;

				// draw cells
				/**
				 * @param {{ nx: number, ny: number, nr: number }} frame
				 * @param {{ rgb: [number, number, number] | [number, number, number, number] }} desc
				 */
				const drawCell = (frame, desc) => {
					const x = (frame.nx - border.l) / gameWidth * canvas.width;
					const y = (frame.ny - border.t) / gameHeight * canvas.height;
					const r = Math.max(frame.nr / gameWidth * canvas.width, 2);

					ctx.scale(0.01, 0.01); // prevent sigmod from treating minimap cells as pellets
					ctx.fillStyle = aux.rgba2hex6(desc.rgb[0], desc.rgb[1], desc.rgb[2], 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();
				};

				/**
				 * @param {number} x
				 * @param {number} y
				 * @param {string} name
				 */
				const drawName = function 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()) {
					const frame = world.synchronized ? cell.merged : cell.views.get(world.selected)?.frames[0];
					/** @type {CellDescription} */
					const desc = world.synchronized ? cell.views.values().next().value : cell.views.get(world.selected);
					if (!frame || !desc || frame.deadAt !== undefined) continue;

					let ownedByOther = false;
					for (const [view, vision] of world.views) {
						if (view === world.selected) continue;
						ownedByOther = vision.owned.has(cell.id) && frame.born >= vision.spawned;
						if (ownedByOther) break;
					}
					if ((!desc.clan || desc.clan !== aux.userData?.clan) && !ownedByOther) continue;

					drawCell(frame, desc);

					const name = desc.name || 'An unnamed cell';
					const hash = desc.name + (desc.rgb[0] * 65536 + desc.rgb[1] * 256 + desc.rgb[2]);
					const entry = avgPos.get(hash);
					if (entry) {
						++entry.n;
						entry.x += frame.nx;
						entry.y += frame.ny;
					} else {
						avgPos.set(hash, { name, n: 1, x: frame.nx, y: frame.ny });
					}
				}

				avgPos.forEach(entry => {
					drawName(entry.x / entry.n, entry.y / entry.n, entry.name);
				});

				// draw my cells above everyone else
				let myName = '';
				let ownN = 0;
				let ownX = 0;
				let ownY = 0;
				for (const id of vision.owned) {
					const cell = world.cells.get(id);
					const frame = world.synchronized ? cell?.merged : cell?.views.get(world.selected)?.frames[0];
					const desc = world.synchronized ? cell?.views.values().next().value : cell?.views.get(world.selected);
					if (!frame || !desc || frame.deadAt !== undefined) continue;

					drawCell(frame, desc);
					myName = desc.name || 'An unnamed cell';
					++ownN;
					ownX += frame.nx;
					ownY += frame.ny;
				}

				if (ownN <= 0) {
					// if no cells were drawn, draw our spectate pos instead
					drawCell({ nx: vision.camera.x, ny: vision.camera.y, nr: gameWidth / canvas.width * 5, },
						{ rgb: settings.theme[3] ? settings.theme : [1, 0.6, 0.6] });
				} else {
					ownX /= ownN;
					ownY /= ownN;
					// draw name above player's cells
					drawName(ownX, ownY, myName);

					// send a hint to sigmod
					ctx.globalAlpha = 0;
					ctx.fillText(`X: ${ownX}, Y: ${ownY}`, 0, -1000);
				}
			})();

			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 = {
		destructor, aux, sigmod, ui, settings, world, net, input, glconf, render,
	};
})();