Multiplayer Piano Optimizations [Drawing]

Draw on the screen!

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Multiplayer Piano Optimizations [Drawing]
// @namespace    https://tampermonkey.net/
// @version      2.6.3
// @description  Draw on the screen!
// @author       zackiboiz
// @match        *://*.multiplayerpiano.com/*
// @match        *://*.multiplayerpiano.net/*
// @match        *://*.multiplayerpiano.org/*
// @match        *://*.multiplayerpiano.dev/*
// @match        *://piano.mpp.community/*
// @match        *://mpp.7458.space/*
// @match        *://qmppv2.qwerty0301.repl.co/*
// @match        *://mpp.8448.space/*
// @match        *://mpp.hri7566.info/*
// @match        *://mpp.autoplayer.xyz/*
// @match        *://mpp.hyye.xyz/*
// @match        *://lmpp.hyye.xyz/*
// @match        *://mpp.hyye.tk/*
// @match        *://mpp.smp-meow.net/*
// @match        *://piano.ourworldofpixels.com/*
// @match        *://mpp.lapishusky.dev/*
// @match        *://staging-mpp.sad.ovh/*
// @match        *://mpp.terrium.net/*
// @match        *://mpp.yourfriend.lv/*
// @match        *://mpp.l3m0ncao.wtf/*
// @match        *://beta-mpp.csys64.com/*
// @match        *://fleetway-mpp.glitch.me/*
// @match        *://mpp.totalh.net/*
// @match        *://mpp.meowbin.com/*
// @match        *://mppfork.netlify.app/*
// @match        *://better.mppclone.me/*
// @match        *://*.openmpp.tk/*
// @match        *://*.mppkinda.com/*
// @match        *://*.augustberchelmann.com/piano/*
// @match        *://mpp.c30.life/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=multiplayerpiano.net
// @grant        GM_info
// @license      MIT
// ==/UserScript==

/*
    MPP `custom` packet format:
    {
        m: "custom",
        data: {
            drawboard: btoa(<uleb128 count*> <<uint8 op> ...>*) // base64 encoded string
        },
        target: {
            mode: "subscribed"
        }
    }


    ### OP 0: Clear user
    - <uint8 op>
    1. Tells clients to clear this user's
    shapes

    ### OP 1: Clear shapes
    - <uint8 op> <uleb128 length*> <uint32 uuid>*
    1. Tells clients to clear shapes with
    uuids provided

    ### OP 2: Quick line
    - <uint8 op> <uint24 color> <uint8 transparency> <uleb128 lineWidth> <uleb128 lifeMs> <uleb128 fadeMs> <uint16 x1> <uint16 y1> <uint16 x2> <uint16 y2> <uint32 uuid>
    1. Tells clients to draw a line from
    (x1, y1) to (x2, y2) with options and
    provides a shape uuid

    ### OP 3: Start chain
    - <uint8 op> <uint24 color> <uint8 transparency> <uleb128 lineWidth> <uleb128 lifeMs> <uleb128 fadeMs> <uint16 x> <uint16 y>
    1. Tells clients to set a point at
    (x, y) to start a chain of lines with
    options

    ### OP 4: Continue chain
    - <uint8 op> <uleb128 length*> <<uint16 x> <uint16 y> <uint32 uuid>>*
    1. Tells clients to continue off of
    the user's chain to point (x, y) and
    provides a shape uuid

    ### OP 5: Filled triangle
    - <uint8 op> <uint24 color> <uint8 transparency> <uleb128 lifeMs> <uleb128 fadeMs> <uint16 x1> <uint16 y1> <uint16 x2> <uint16 y2> <uint16 x3> <uint16 y3> <uint32 uuid>
    1. Tells clients to draw a filled triangle
    with the provided vertices and options,
    and provides a shape uuid.

    ### OP 6: Stroked ellipse
    - <uint8 op> <uint24 color> <uint8 transparency> <uleb128 lineWidth> <uleb128 lifeMs> <uleb128 fadeMs> <uint16 cx> <uint16 cy> <uint16 rx> <uint16 ry> <uint32 uuid>
    1. Tells clients to draw a stroked ellipse
    at center (cx, cy) with radii (rx, ry)

    ### OP 7: Filled ellipse
    - <uint8 op> <uint24 color> <uint8 transparency> <uleb128 lifeMs> <uleb128 fadeMs> <uint16 cx> <uint16 cy> <uint16 rx> <uint16 ry> <uint32 uuid>
    1. Tells clients to draw a filled ellipse
    at center (cx, cy) with radii (rx, ry)

    ### OP 8: Text
    - <uint8 op> <uint24 color> <uint8 transparency> <uleb128 fontSize> <uleb128 lifeMs> <uleb128 fadeMs> <uint16 x> <uint16 y> <string text> <bitfield8 options> <uint32 uuid>
    - <bitfield8 options>:
        - <uint2 align>:
            0 - left
            1 - right
            2 - center
            3 - [none]
        - <boolean styleBold>
        - <boolean styleItalic>
        - <boolean styleUnderline>
        - <boolean styleLineThrough> (strikethrough)
        - <uint2 font>:
            0 - Verdana, "DejaVu Sans", sans-serif (MPP)
            1 - "Times New Roman", Times, Georgia, Garamond, serif
            2 - "Lucida Console", "Courier New", Monaco, monospace
            3 - "Brush Script MT", "Lucida Handwriting", cursive
    1. Tells clients to draw a stroked text
    area at (x, y) with options

    ### OP 9: Stroked polygon
    - <uint8 op> <uint24 color> <uint8 transparency> <uleb128 lineWidth> <uleb128 lifeMs> <uleb128 fadeMs> <uleb128 length*> <<uint16 x> <uint16 y>>* <uint32 uuid>
    1. Tells clients to draw a stroked polygon
    with a multitude of points

    ### OP 10: Filled polygon
    - <uint8 op> <uint24 color> <uint8 transparency> <uleb128 lifeMs> <uleb128 fadeMs> <uleb128 length*> <<uint16 x> <uint16 y>>* <uint32 uuid>
    1. Tells clients to draw a filled polygon
    with a multitude of points

    strings are prefixed with <uleb128 length>
    * Denotes multiple allowed
*/

(async () => {
    const dl = GM_info.script.downloadURL || GM_info.script.updateURL || GM_info.script.homepageURL || "";
    const match = dl.match(/greasyfork\.org\/scripts\/(\d+)/);
    if (!match) {
        console.warn("Could not find Greasy Fork script ID in downloadURL/updateURL/homepageURL:", dl);
    } else {
        const scriptId = match[1];
        const localVersion = GM_info.script.version;
        const apiUrl = `https://greasyfork.org/scripts/${scriptId}.json?_=${Date.now()}`;

        fetch(apiUrl, {
            mode: "cors",
            headers: {
                Accept: "application/json"
            }
        }).then(r => {
            if (!r.ok) throw new Error("Failed to fetch Greasy Fork data.");
            return r.json();
        }).then(data => {
            const remoteVersion = data.version;
            if (compareVersions(localVersion, remoteVersion) < 0) {
                new MPP.Notification({
                    "m": "notification",
                    "duration": 15000,
                    "title": "Update Available",
                    "html": "<p>A new version of this script is available!</p>" +
                        `<p style='margin-top: 10px;'>Script: ${GM_info.script.name}</p>` +
                        `<p>Local: v${localVersion}</p>` +
                        `<p>Latest: v${remoteVersion}</p>` +
                        `<a href='https://greasyfork.org/scripts/${scriptId}' target='_blank' style='position: absolute; right: 0;bottom: 0; margin: 10px; font-size: 0.5rem;'>Open Greasy Fork to update?</a>`
                });
            }
        }).catch(err => console.error("Update check failed:", err));
    }

    function compareVersions(a, b) {
        const pa = a.split(".").map(n => parseInt(n, 10) || 0);
        const pb = b.split(".").map(n => parseInt(n, 10) || 0);
        const len = Math.max(pa.length, pb.length);
        for (let i = 0; i < len; i++) {
            if ((pa[i] || 0) < (pb[i] || 0)) return -1;
            if ((pa[i] || 0) > (pb[i] || 0)) return 1;
        }
        return 0;
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    await sleep(1000);


    Math.clamp = (min, x, max) => Math.min(max, Math.max(min, x));

    class Drawboard {
        static TextAlign = {
            LEFT: "left",
            RIGHT: "right",
            CENTER: "center"
        };
        static FontStyle = ["bold", "italic", "underline", "line-through"];
        static FontFamily = {
            SANS_SERIF: "Verdana, \"DejaVu Sans\", sans-serif",
            SERIF: "\"Times New Roman\", Times, Georgia, Garamond, serif",
            MONOSPACE: "\"Lucida Console\", \"Courier New\", Monaco, monospace",
            CURSIVE: "\"Brush Script MT\", \"Lucida Handwriting\", cursive"
        };

        #canvas;
        #ctx;
        #offscreenCanvas;
        #offscreenCtx;

        #enabled = true;
        #isShiftDown = false;
        #isCtrlDown = false;
        #clicking = false;
        #lastPosition;
        #position;

        #color = "#000000";
        #transparency = 1;
        #lineWidth = 3;
        #eraseFactor = 8;
        #lifeMs = 5000;
        #fadeMs = 3000;
        #textAlign = Drawboard.TextAlign.LEFT;
        #fontStyle = [];
        #fontFamily = Drawboard.FontFamily.SANS_SERIF;
        #fontSize = 12;

        #shapeBuffer = [];
        #opBuffer = [];
        #drawingMutes = [];
        #payloadFlushMs = 200;
        #flushInterval;
        #mouseMoveThrottleMs = 50;
        #lastMouseMoveAt = 0;
        #chains = new Map();
        #localChainStarted = false;

        constructor() {
            this.#canvas = document.createElement("canvas");
            this.#canvas.id = "drawboard";
            this.#canvas.style.position = "absolute";
            this.#canvas.style.top = "0px";
            this.#canvas.style.left = "0px";
            this.#canvas.style.zIndex = "800";
            this.#canvas.style.pointerEvents = "none";
            document.documentElement.appendChild(this.#canvas);
            this.#ctx = this.#canvas.getContext("2d");

            this.#offscreenCanvas = document.createElement("canvas");
            this.#offscreenCanvas.width = 1;
            this.#offscreenCanvas.height = 1;
            this.#offscreenCtx = this.#offscreenCanvas.getContext("2d");

            this.#resize();
            this.#init();
        }

        get participant() {
            return MPP.client.getOwnParticipant();
        }
        get participants() {
            return MPP.client.ppl;
        }

        get canvas() {
            return this.#canvas;
        }
        get ctx() {
            return this.#ctx;
        }
        static get connected() {
            return !!(MPP && MPP.client && MPP.client.isConnected() && MPP.client.channel && MPP.client.user && MPP.client.ppl);
        }
        get enabled() {
            return this.#enabled;
        }
        get lastPosition() {
            return this.#lastPosition;
        }
        get position() {
            return this.#position;
        }
        get color() {
            return this.#color;
        }
        get transparency() {
            return this.#transparency;
        }
        get lineWidth() {
            return this.#lineWidth;
        }
        get eraseFactor() {
            return this.#eraseFactor;
        }
        get lifeMs() {
            return this.#lifeMs;
        }
        get fadeMs() {
            return this.#fadeMs;
        }
        get textAlign() {
            return this.#textAlign;
        }
        get fontStyle() {
            return this.#fontStyle;
        }
        get fontFamily() {
            return this.#fontFamily;
        }
        get fontSize() {
            return this.#fontSize;
        }
        get mouseMoveThrottleMs() {
            return this.#mouseMoveThrottleMs;
        }
        get payloadFlushMs() {
            return this.#payloadFlushMs;
        }
        get drawingMutes() {
            return this.#drawingMutes;
        }

        set enabled(enabled) {
            this.#enabled = enabled;
        }
        set color(color) {
            this.#color = color;
        }
        set transparency(transparency) {
            this.#transparency = Math.max(0, Math.min(1, transparency));
        }
        set lineWidth(lineWidth) {
            this.#lineWidth = lineWidth;
        }
        set eraseFactor(eraseFactor) {
            this.#eraseFactor = eraseFactor;
        }
        set lifeMs(lifeMs) {
            this.#lifeMs = lifeMs;
        }
        set fadeMs(fadeMs) {
            this.#fadeMs = fadeMs;
        }
        set textAlign(textAlign) {
            if (!Object.values(Drawboard.TextAlign).includes(textAlign)) throw new Error("Invalid text align.");
            this.#textAlign = textAlign;
        }
        set fontStyle(fontStyle) {
            this.#fontStyle = fontStyle.filter(style => Drawboard.FontStyle.includes(style));
        }
        set fontFamily(fontFamily) {
            if (!Object.values(Drawboard.FontFamily).includes(fontFamily)) throw new Error("Invalid font family.");
            this.#fontFamily = fontFamily;
        }
        set fontSize(fontSize) {
            this.#fontSize = fontSize;
        }
        set mouseMoveThrottleMs(mouseMoveThrottleMs) {
            this.#mouseMoveThrottleMs = mouseMoveThrottleMs;
        }
        set payloadFlushMs(payloadFlushMs) {
            clearInterval(this.#flushInterval);
            this.#payloadFlushMs = payloadFlushMs;
            this.#flushInterval = setInterval(this.#flushOpBuffer, this.#payloadFlushMs);
        }
        set drawingMutes(drawingMutes) {
            this.#drawingMutes = drawingMutes;
            this.#saveDrawingMutes();
        }


        #resize = () => {
            this.#canvas.width = window.innerWidth;
            this.#canvas.height = window.innerHeight;
        }

        #init = () => {
            window.addEventListener("resize", this.#resize);
            document.addEventListener("keydown", (e) => {
                this.#isShiftDown = e.shiftKey;
                this.#isCtrlDown = e.ctrlKey || e.metaKey;
            });
            document.addEventListener("keyup", (e) => {
                this.#isShiftDown = e.shiftKey;
                this.#isCtrlDown = e.ctrlKey || e.metaKey;
            });
            document.addEventListener("mousedown", (e) => {
                this.#updatePosition();
                this.#clicking = true;
                this.#localChainStarted = false;

                if ((this.#isShiftDown || this.#isCtrlDown) && this.#clicking) {
                    e.preventDefault();
                }
            });
            document.addEventListener("mouseup", (e) => {
                this.#updatePosition();
                this.#clicking = false;
                this.#flushOpBuffer();
            });
            document.addEventListener("mousemove", (e) => {
                const now = Date.now();
                if (now - this.#lastMouseMoveAt < this.#mouseMoveThrottleMs) return;
                this.#lastMouseMoveAt = now;

                this.#updatePosition();
                if (!this.#lastPosition) this.#lastPosition = this.#position;
                if (this.#isShiftDown && this.#clicking) {
                    const start = this.#lastPosition;
                    const end = this.#position;

                    this.drawLine({
                        x1: start.x,
                        y1: start.y,
                        x2: end.x,
                        y2: end.y,
                        color: this.#color,
                        transparency: this.#transparency,
                        lineWidth: this.#lineWidth,
                        lifeMs: this.#lifeMs,
                        fadeMs: this.#fadeMs
                    });

                    this.#lastPosition = this.#position;
                } else if (this.#isCtrlDown && this.#clicking) {
                    const maxDim = Math.max(this.#canvas.width, this.#canvas.height) || 1;
                    const radius = this.#lineWidth * this.#eraseFactor / maxDim;

                    const removedUUIDs = this.erase({
                        x: this.#position.x,
                        y: this.#position.y,
                        radius: radius
                    });

                    if (removedUUIDs && removedUUIDs.length) {
                        this.#pushOp({
                            op: 1,
                            uuids: removedUUIDs.map(n => Number(n) >>> 0)
                        });
                    }
                }
            });

            const menuClassName = "participant-menu";
            const observer = new MutationObserver((mutations) => {
                mutations.forEach((mutation) => {
                    mutation.addedNodes.forEach((node) => {
                        if (node.nodeType === Node.ELEMENT_NODE && node.className === menuClassName) {
                            const menu = document.querySelector(`.${menuClassName}`);

                            const info = menu.querySelector("div.info");
                            const targetId = info.textContent.trim();
                            const muted = this.#drawingMutes.includes(targetId);

                            const muteLinesButton = document.createElement("div");
                            muteLinesButton.className = "menu-item";
                            muteLinesButton.textContent = `${muted ? "Unhide" : "Hide"} Drawings`;
                            menu.insertAdjacentElement("beforeend", muteLinesButton);

                            muteLinesButton.addEventListener("click", (e) => {
                                if (muted) {
                                    this.#drawingMutes = this.#drawingMutes.filter(id => id !== targetId);
                                } else {
                                    this.#drawingMutes.push(targetId);
                                }
                                this.#saveDrawingMutes();
                            });
                        }
                    });
                });
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true
            });

            requestAnimationFrame(this.#draw);
            this.#flushInterval = setInterval(this.#flushOpBuffer, this.#payloadFlushMs);

            window.addEventListener("beforeunload", () => {
                if (this.#flushInterval) clearInterval(this.#flushInterval);
            });

            const participant = this.participant;
            if (participant?.color) this.#color = participant.color;
            this.#drawingMutes = localStorage.drawingMutes?.split(",") ?? [];
            this.#saveDrawingMutes();
        }

        #saveDrawingMutes = () => {
            localStorage.drawingMutes = this.#drawingMutes.filter(id => id).join(",");
        }

        #readUint8 = (bytes, state) => {
            if (state.i >= bytes.length) throw new Error("Unexpected end of payload (uint8).");
            return bytes[state.i++];
        }
        #writeUint8 = (bytes, val) => {
            bytes.push(val & 0xFF);
        }

        #readUint16 = (bytes, state) => {
            if (state.i + 2 > bytes.length) throw new Error("Unexpected end of payload (uint16).");
            const v = bytes[state.i] | (bytes[state.i + 1] << 8);
            state.i += 2;
            return v >>> 0;
        }
        #writeUint16 = (bytes, val) => {
            const v = val >>> 0;
            bytes.push(v & 0xFF, (v >>> 8) & 0xFF);
        }

        #readUint32 = (bytes, state) => {
            if (state.i + 4 > bytes.length) throw new Error("Unexpected end of payload (uint32).");
            const v = (bytes[state.i] | (bytes[state.i + 1] << 8) | (bytes[state.i + 2] << 16) | (bytes[state.i + 3] << 24)) >>> 0;
            state.i += 4;
            return v;
        }
        #writeUint32 = (bytes, val) => {
            const v = val >>> 0;
            bytes.push(v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF);
        }

        #readULEB128 = (bytes, state) => {
            let result = 0;
            let shift = 0;
            while (true) {
                if (state.i >= bytes.length) throw new Error("Unexpected end of payload (uleb128).");
                const b = bytes[state.i++];
                result |= (b & 0x7F) << shift;
                if (!(b & 0x80)) break;
                shift += 7;
            }
            return result >>> 0;
        }
        #writeULEB128 = (bytes, val) => {
            val = Math.max(0, Math.floor(val));
            while (val > 0x7F) {
                bytes.push((val & 0x7F) | 0x80);
                val >>>= 7;
            }

            bytes.push(val & 0x7F);
        }

        #readColor = (bytes, state) => {
            if (state.i + 3 > bytes.length) throw new Error("Unexpected end of payload (color).");
            const r = bytes[state.i++];
            const g = bytes[state.i++];
            const b = bytes[state.i++];
            return "#" + [r, g, b].map(x => x.toString(16).padStart(2, "0")).join("");
        }
        #writeColor = (bytes, hex) => {
            let part = [0, 0, 0];
            if (hex) {
                if (hex.startsWith("#")) hex = hex.slice(1);
                if (hex.length === 3) hex = hex.split("").map(c => c + c).join("");
                const num = parseInt(hex, 16) || 0;
                part = [(num >> 16) & 0xFF, (num >> 8) & 0xFF, num & 0xFF];
            }

            bytes.push(...part);
        }

        #readString = (bytes, state) => {
            const len = this.#readULEB128(bytes, state);
            if (state.i + len > bytes.length) throw new Error("Unexpected end of payload (string).");
            const slice = bytes.slice(state.i, state.i + len);
            state.i += len;
            const textBytes = new Uint8Array(slice);
            const text = new TextDecoder().decode(textBytes);

            return text;
        }
        #writeString = (bytes, strOrBytes) => {
            if (strOrBytes instanceof Uint8Array) {
                this.#writeULEB128(bytes, strOrBytes.length);
                for (const b of strOrBytes) bytes.push(b);
                return;
            }

            const textEncoder = new TextEncoder();
            const buf = textEncoder.encode(String(strOrBytes));
            this.#writeULEB128(bytes, buf.length);
            for (const b of buf) bytes.push(b);
        }

        generateUUID = () => {
            let id = 0;
            if (typeof crypto !== "undefined" && crypto.getRandomValues) {
                const arr = new Uint32Array(1);
                crypto.getRandomValues(arr);
                id = arr[0] >>> 0;
            } else {
                id = Math.floor(Math.random() * 0xFFFFFFFF) >>> 0;
            }
            if (id === 0) id = 1;
            return id >>> 0;
        }

        #removeShapesByUUIDs = (uuids) => {
            const removed = [];
            const set = new Set(uuids.map(n => Number(n)));
            for (let i = this.#shapeBuffer.length - 1; i >= 0; i--) {
                const shape = this.#shapeBuffer[i];
                if (set.has(Number(shape.uuid))) {
                    removed.push(shape.uuid);
                    this.#shapeBuffer.splice(i, 1);
                }
            }
            return Array.from(new Set(removed));
        }

        #removeLinesByOwner = (ownerId) => {
            const removed = [];
            for (let i = this.#shapeBuffer.length - 1; i >= 0; i--) {
                const shape = this.#shapeBuffer[i];
                if (shape.owner === ownerId) {
                    if (shape.uuid) removed.push(shape.uuid);
                    this.#shapeBuffer.splice(i, 1);
                }
            }
            this.#chains.delete(ownerId);
            return Array.from(new Set(removed));
        }

        #pushOp = (opObj) => { // could do some stuff here but dont need to atm
            this.#opBuffer.push(opObj);
        }

        #buildClearUserPacket = () => {
            const bytes = [];
            this.#writeUint8(bytes, 0);
            return bytes;
        }

        #buildClearLinesPacket = (uuids) => {
            const bytes = [];
            this.#writeUint8(bytes, 1);
            this.#writeULEB128(bytes, uuids.length);
            for (const u of uuids) this.#writeUint32(bytes, Number(u) >>> 0);
            return bytes;
        }

        #buildQuickLinePacket = (color, transparency, lineWidth, lifeMs, fadeMs, x1, y1, x2, y2, uuid) => {
            const bytes = [];
            this.#writeUint8(bytes, 2);
            this.#writeColor(bytes, color);
            this.#writeUint8(bytes, Math.floor(Math.clamp(0, transparency, 1) * 255) & 0xFF);
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lineWidth)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lifeMs)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(fadeMs)));
            this.#writeUint16(bytes, x1 & 0xFFFF);
            this.#writeUint16(bytes, y1 & 0xFFFF);
            this.#writeUint16(bytes, x2 & 0xFFFF);
            this.#writeUint16(bytes, y2 & 0xFFFF);
            this.#writeUint32(bytes, uuid >>> 0);
            return bytes;
        }

        #buildStartChainPacket = (color, transparency, lineWidth, lifeMs, fadeMs, x, y) => {
            const bytes = [];
            this.#writeUint8(bytes, 3);
            this.#writeColor(bytes, color);
            this.#writeUint8(bytes, Math.floor(Math.clamp(0, transparency, 1) * 255) & 0xFF);
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lineWidth)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lifeMs)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(fadeMs)));
            this.#writeUint16(bytes, x & 0xFFFF);
            this.#writeUint16(bytes, y & 0xFFFF);
            return bytes;
        }

        #buildContinueChainPacket = (entries) => {
            const bytes = [];
            this.#writeUint8(bytes, 4);
            this.#writeULEB128(bytes, entries.length);
            for (const e of entries) {
                this.#writeUint16(bytes, e.x & 0xFFFF);
                this.#writeUint16(bytes, e.y & 0xFFFF);
                this.#writeUint32(bytes, e.uuid >>> 0);
            }
            return bytes;
        }

        #buildTrianglePacket = (color, transparency, lifeMs, fadeMs, x1, y1, x2, y2, x3, y3, uuid) => {
            const bytes = [];
            this.#writeUint8(bytes, 5);
            this.#writeColor(bytes, color);
            this.#writeUint8(bytes, Math.floor(Math.clamp(0, transparency, 1) * 255) & 0xFF);
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lifeMs)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(fadeMs)));
            this.#writeUint16(bytes, x1 & 0xFFFF);
            this.#writeUint16(bytes, y1 & 0xFFFF);
            this.#writeUint16(bytes, x2 & 0xFFFF);
            this.#writeUint16(bytes, y2 & 0xFFFF);
            this.#writeUint16(bytes, x3 & 0xFFFF);
            this.#writeUint16(bytes, y3 & 0xFFFF);
            this.#writeUint32(bytes, uuid >>> 0);
            return bytes;
        }

        #buildEllipseStrokePacket = (color, transparency, lineWidth, lifeMs, fadeMs, cx, cy, rx, ry, uuid) => {
            const bytes = [];
            this.#writeUint8(bytes, 6);
            this.#writeColor(bytes, color);
            this.#writeUint8(bytes, Math.floor(Math.clamp(0, transparency, 1) * 255) & 0xFF);
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lineWidth)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lifeMs)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(fadeMs)));
            this.#writeUint16(bytes, cx & 0xFFFF);
            this.#writeUint16(bytes, cy & 0xFFFF);
            this.#writeUint16(bytes, rx & 0xFFFF);
            this.#writeUint16(bytes, ry & 0xFFFF);
            this.#writeUint32(bytes, uuid >>> 0);
            return bytes;
        }

        #buildEllipseFillPacket = (color, transparency, lifeMs, fadeMs, cx, cy, rx, ry, uuid) => {
            const bytes = [];
            this.#writeUint8(bytes, 7);
            this.#writeColor(bytes, color);
            this.#writeUint8(bytes, Math.floor(Math.clamp(0, transparency, 1) * 255) & 0xFF);
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lifeMs)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(fadeMs)));
            this.#writeUint16(bytes, cx & 0xFFFF);
            this.#writeUint16(bytes, cy & 0xFFFF);
            this.#writeUint16(bytes, rx & 0xFFFF);
            this.#writeUint16(bytes, ry & 0xFFFF);
            this.#writeUint32(bytes, uuid >>> 0);
            return bytes;
        }

        #buildTextPacket = (color, transparency, fontSize, lifeMs, fadeMs, x, y, text, options, uuid) => {
            const bytes = [];
            this.#writeUint8(bytes, 8);
            this.#writeColor(bytes, color);
            this.#writeUint8(bytes, Math.floor(Math.clamp(0, transparency, 1) * 255) & 0xFF);
            this.#writeULEB128(bytes, Math.max(0, Math.floor(fontSize)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lifeMs)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(fadeMs)));
            this.#writeUint16(bytes, x & 0xFFFF);
            this.#writeUint16(bytes, y & 0xFFFF);
            this.#writeString(bytes, text);
            this.#writeUint8(bytes, options & 0xFF);
            this.#writeUint32(bytes, uuid >>> 0);
            return bytes;
        }

        #buildPolygonStrokePacket = (color, transparency, lineWidth, lifeMs, fadeMs, vertices, uuid) => {
            const bytes = [];
            this.#writeUint8(bytes, 9);
            this.#writeColor(bytes, color);
            this.#writeUint8(bytes, Math.floor(Math.clamp(0, transparency, 1) * 255) & 0xFF);
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lineWidth)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lifeMs)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(fadeMs)));
            this.#writeULEB128(bytes, vertices.length);
            for (const v of vertices) {
                this.#writeUint16(bytes, v.xu & 0xFFFF);
                this.#writeUint16(bytes, v.yu & 0xFFFF);
            }
            this.#writeUint32(bytes, uuid >>> 0);
            return bytes;
        }

        #buildPolygonFillPacket = (color, transparency, lifeMs, fadeMs, vertices, uuid) => {
            const bytes = [];
            this.#writeUint8(bytes, 10);
            this.#writeColor(bytes, color);
            this.#writeUint8(bytes, Math.floor(Math.clamp(0, transparency, 1) * 255) & 0xFF);
            this.#writeULEB128(bytes, Math.max(0, Math.floor(lifeMs)));
            this.#writeULEB128(bytes, Math.max(0, Math.floor(fadeMs)));
            this.#writeULEB128(bytes, vertices.length);
            for (const v of vertices) {
                this.#writeUint16(bytes, v.xu & 0xFFFF);
                this.#writeUint16(bytes, v.yu & 0xFFFF);
            }
            this.#writeUint32(bytes, uuid >>> 0);
            return bytes;
        }

        #sendCustomData = (payload) => {
            if (!MPP?.client?.sendArray || !Drawboard.connected) return;

            MPP.client.sendArray([{
                m: "custom",
                data: {
                    drawboard: btoa(payload)
                },
                target: {
                    mode: "subscribed"
                }
            }]);
        }

        #updatePosition = () => {
            this.#lastPosition = this.#position;
            const participant = this.participant;
            this.#position = {
                x: Math.clamp(0, (participant?.x ?? 0), 100) / 100,
                y: Math.clamp(0, (participant?.y ?? 0), 100) / 100
            };
        }

        #flushOpBuffer = () => {
            if (!this.#opBuffer.length) return;

            const builtOps = [];
            const buf = this.#opBuffer;
            let i = 0;
            while (i < buf.length) {
                const item = buf[i];
                if (!item || typeof item.op !== "number") {
                    i++;
                    continue;
                }

                switch (item.op) {
                    case 0: {
                        builtOps.push(this.#buildClearUserPacket());
                        i++;
                        break;
                    }
                    case 1: {
                        const allU = [];
                        let j = i;
                        while (j < buf.length && buf[j] && buf[j].op === 1) {
                            if (Array.isArray(buf[j].uuids)) allU.push(...buf[j].uuids.map(n => Number(n) >>> 0));
                            j++;
                        }
                        const seen = new Set();
                        const uniq = [];
                        for (const u of allU) {
                            if (!seen.has(u)) {
                                seen.add(u);
                                uniq.push(u);
                            }
                        }
                        builtOps.push(this.#buildClearLinesPacket(uniq));
                        i = j;
                        break;
                    }
                    case 2: {
                        builtOps.push(this.#buildQuickLinePacket(
                            item.color,
                            item.transparency,
                            item.lineWidth,
                            item.lifeMs,
                            item.fadeMs,
                            item.x1u,
                            item.y1u,
                            item.x2u,
                            item.y2u,
                            item.uuid >>> 0
                        ));
                        i++;
                        break;
                    }
                    case 3: {
                        builtOps.push(this.#buildStartChainPacket(
                            item.color,
                            item.transparency,
                            item.lineWidth,
                            item.lifeMs,
                            item.fadeMs,
                            item.xu,
                            item.yu
                        ));
                        i++;
                        break;
                    }
                    case 4: {
                        const allEntries = [];
                        let j = i;
                        while (j < buf.length && buf[j] && buf[j].op === 4) {
                            if (Array.isArray(buf[j].entries)) allEntries.push(...buf[j].entries.map(e => ({
                                x: e.x & 0xFFFF,
                                y: e.y & 0xFFFF,
                                uuid: e.uuid >>> 0
                            })));
                            j++;
                        }
                        builtOps.push(this.#buildContinueChainPacket(allEntries));
                        i = j;
                        break;
                    }
                    case 5: {
                        builtOps.push(this.#buildTrianglePacket(
                            item.color,
                            item.transparency,
                            item.lifeMs,
                            item.fadeMs,
                            item.x1u,
                            item.y1u,
                            item.x2u,
                            item.y2u,
                            item.x3u,
                            item.y3u,
                            item.uuid >>> 0
                        ));
                        i++;
                        break;
                    }
                    case 6: {
                        builtOps.push(this.#buildEllipseStrokePacket(
                            item.color,
                            item.transparency,
                            item.lineWidth,
                            item.lifeMs,
                            item.fadeMs,
                            item.cxu,
                            item.cyu,
                            item.rxu,
                            item.ryu,
                            item.uuid >>> 0
                        ));
                        i++;
                        break;
                    }
                    case 7: {
                        builtOps.push(this.#buildEllipseFillPacket(
                            item.color,
                            item.transparency,
                            item.lifeMs,
                            item.fadeMs,
                            item.cxu,
                            item.cyu,
                            item.rxu,
                            item.ryu,
                            item.uuid >>> 0
                        ));
                        i++;
                        break;
                    }
                    case 8: {
                        builtOps.push(this.#buildTextPacket(
                            item.color,
                            item.transparency,
                            item.fontSize,
                            item.lifeMs,
                            item.fadeMs,
                            item.xu,
                            item.yu,
                            item.text,
                            item.options,
                            item.uuid >>> 0
                        ));
                        i++;
                        break;
                    }
                    case 9: {
                        builtOps.push(this.#buildPolygonStrokePacket(
                            item.color,
                            item.transparency,
                            item.lineWidth,
                            item.lifeMs,
                            item.fadeMs,
                            item.vertices,
                            item.uuid >>> 0
                        ));
                        i++;
                        break;
                    }
                    case 10: {
                        builtOps.push(this.#buildPolygonFillPacket(
                            item.color,
                            item.transparency,
                            item.lifeMs,
                            item.fadeMs,
                            item.vertices,
                            item.uuid >>> 0
                        ));
                        i++;
                        break;
                    }
                    default: {
                        i++;
                        break;
                    }
                }
            }

            // final payload
            const bytes = [];
            this.#writeULEB128(bytes, builtOps.length);
            for (const opBytes of builtOps) {
                for (const b of opBytes) bytes.push(b);
            }
            const finalPayload = String.fromCharCode(...bytes);
            this.#sendCustomData(finalPayload);
            this.#opBuffer.length = 0;
        }

        #pointToSegmentDistance = (px, py, x1, y1, x2, y2) => {
            const vx = x2 - x1;
            const vy = y2 - y1;
            const wx = px - x1;
            const wy = py - y1;
            const c = (wx * vx + wy * vy);
            const d = (vx * vx + vy * vy);
            let t = 0;
            if (d !== 0) t = Math.max(0, Math.min(1, c / d));
            const projx = x1 + t * vx;
            const projy = y1 + t * vy;
            const dx = px - projx;
            const dy = py - projy;
            return Math.sqrt(dx * dx + dy * dy);
        }

        // https://www.geeksforgeeks.org/dsa/check-whether-a-given-point-lies-inside-a-triangle-or-not/
        #pointInTriangle = (px, py, ax, ay, bx, by, cx, cy) => {
            const v0x = cx - ax;
            const v0y = cy - ay;
            const v1x = bx - ax;
            const v1y = by - ay;
            const v2x = px - ax;
            const v2y = py - ay;

            const dot00 = v0x * v0x + v0y * v0y;
            const dot01 = v0x * v1x + v0y * v1y;
            const dot02 = v0x * v2x + v0y * v2y;
            const dot11 = v1x * v1x + v1y * v1y;
            const dot12 = v1x * v2x + v1y * v2y;

            const denom = dot00 * dot11 - dot01 * dot01;
            if (denom === 0) return false;
            const invDenom = 1 / denom;
            const u = (dot11 * dot02 - dot01 * dot12) * invDenom;
            const v = (dot00 * dot12 - dot01 * dot02) * invDenom;
            return (u >= 0) && (v >= 0) && (u + v < 1);
        }

        #pointInPolygon = (px, py, vertices) => {
            if (!Array.isArray(vertices) || vertices.length < 3) return false;

            const a = vertices[0];
            for (let i = 1; i < vertices.length - 1; i++) {
                const b = vertices[i];
                const c = vertices[i + 1];
                if (this.#pointInTriangle(px, py, a.x, a.y, b.x, b.y, c.x, c.y)) return true;
            }
            return false;
        };


        #clear = () => {
            this.#ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height);
        }

        #draw = () => {
            if (!this.enabled || !Drawboard.connected) {
                requestAnimationFrame(this.#draw);
                return;
            }

            this.#clear();
            const now = Date.now();

            const kept = [];
            for (let i = 0; i < this.#shapeBuffer.length; i++) {
                const shape = this.#shapeBuffer[i];
                const timestamp = shape.timestamp || 0;
                const age = now - timestamp;
                if (age < (shape.lifeMs + shape.fadeMs)) {
                    kept.push(shape);
                }
            }
            this.#shapeBuffer = kept;
            this.#shapeBuffer.sort((a, b) => a.timestamp - b.timestamp);

            for (let i = 0; i < this.#shapeBuffer.length; i++) {
                const shape = this.#shapeBuffer[i];
                if (this.#drawingMutes.includes(shape.owner)) continue; // user could unhide if they want to see it back

                const timestamp = shape.timestamp;
                const lifeMs = shape.lifeMs;
                const fadeMs = shape.fadeMs;
                const age = now - timestamp;

                let alpha = 1;
                if (age > lifeMs) {
                    const fadeAge = age - lifeMs;
                    alpha = Math.clamp(0, 1 - (fadeAge / fadeMs), 1);
                }

                this.#ctx.globalAlpha = alpha * shape.transparency;
                switch (shape.type) {
                    case "line": {
                        this.#ctx.globalCompositeOperation = "source-over";
                        this.#ctx.strokeStyle = shape.color;
                        this.#ctx.lineWidth = shape.lineWidth;
                        this.#ctx.beginPath();
                        this.#ctx.moveTo(shape.x1 * this.#canvas.width, shape.y1 * this.#canvas.height);
                        this.#ctx.lineTo(shape.x2 * this.#canvas.width, shape.y2 * this.#canvas.height);
                        this.#ctx.stroke();
                        break;
                    }
                    case "triangle": {
                        this.#ctx.globalCompositeOperation = "source-over";
                        this.#ctx.fillStyle = shape.color;
                        this.#ctx.beginPath();
                        this.#ctx.moveTo(shape.x1 * this.#canvas.width, shape.y1 * this.#canvas.height);
                        this.#ctx.lineTo(shape.x2 * this.#canvas.width, shape.y2 * this.#canvas.height);
                        this.#ctx.lineTo(shape.x3 * this.#canvas.width, shape.y3 * this.#canvas.height);
                        this.#ctx.closePath();
                        this.#ctx.fill();
                        break;
                    }
                    case "ellipse": {
                        this.#ctx.globalCompositeOperation = "source-over";

                        const cx = shape.cx * this.#canvas.width;
                        const cy = shape.cy * this.#canvas.height;
                        const rx = shape.rx * this.#canvas.width;
                        const ry = shape.ry * this.#canvas.height;
                        this.#ctx.beginPath();

                        this.#ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
                        switch (shape.subType) {
                            case "fill": {
                                this.#ctx.fillStyle = shape.color;
                                this.#ctx.fill();
                                break;
                            }
                            case "stroke": {
                                this.#ctx.strokeStyle = shape.color;
                                this.#ctx.lineWidth = shape.lineWidth;
                                this.#ctx.stroke();
                                break;
                            }
                            default: {
                                console.warn("Unknown drawboard shape subtype:", shape.subType);
                                break;
                            }
                        }
                        break;
                    }
                    case "text": {
                        this.#ctx.globalCompositeOperation = "source-over";

                        const options = shape.options || 0;
                        const align = options & 0x03;
                        const bold = !!(options & 0x04);
                        const italic = !!(options & 0x08);
                        const underline = !!(options & 0x10);
                        const lineThrough = !!(options & 0x20);
                        const fontIndex = (options >> 6) & 0x03;

                        const families = [
                            Drawboard.FontFamily.SANS_SERIF,
                            Drawboard.FontFamily.SERIF,
                            Drawboard.FontFamily.MONOSPACE,
                            Drawboard.FontFamily.CURSIVE
                        ];
                        const family = families[fontIndex] || Drawboard.FontFamily.SANS_SERIF;

                        const style = (italic ? "italic " : "") + (bold ? "bold " : "");
                        const fontSize = Math.max(1, Number(shape.fontSize) || 12);
                        this.#ctx.font = `${style}${fontSize}px ${family}`;

                        let textAlign = this.#textAlign;
                        if (align === 0) textAlign = Drawboard.TextAlign.LEFT;
                        else if (align === 1) textAlign = Drawboard.TextAlign.RIGHT;
                        else if (align === 2) textAlign = Drawboard.TextAlign.CENTER;
                        this.#ctx.textAlign = textAlign;
                        this.#ctx.textBaseline = "top";

                        const x = shape.x * this.#canvas.width;
                        const y = shape.y * this.#canvas.height;
                        this.#ctx.fillStyle = shape.color;
                        this.#ctx.fillText(shape.text, x, y);

                        const metrics = this.#ctx.measureText(shape.text || "");
                        const textWidth = metrics.width || 0;

                        if (underline) {
                            const uy = y + fontSize;
                            this.#ctx.beginPath();
                            this.#ctx.lineWidth = Math.max(1, Math.floor(fontSize / 12) || 1);
                            this.#ctx.strokeStyle = shape.color;
                            let startX = x;

                            if (textAlign === Drawboard.TextAlign.CENTER) startX = x - textWidth / 2;
                            else if (textAlign === Drawboard.TextAlign.RIGHT) startX = x - textWidth;

                            this.#ctx.moveTo(startX, uy);
                            this.#ctx.lineTo(startX + textWidth, uy);
                            this.#ctx.stroke();
                        }
                        if (lineThrough) {
                            const ly = y + fontSize * 0.5;
                            this.#ctx.beginPath();
                            this.#ctx.lineWidth = Math.max(1, Math.floor(fontSize / 12) || 1);
                            this.#ctx.strokeStyle = shape.color;
                            let startX = x;

                            if (textAlign === Drawboard.TextAlign.CENTER) startX = x - textWidth / 2;
                            else if (textAlign === Drawboard.TextAlign.RIGHT) startX = x - textWidth;

                            this.#ctx.moveTo(startX, ly);
                            this.#ctx.lineTo(startX + textWidth, ly);
                            this.#ctx.stroke();
                        }
                        break;
                    }
                    case "polygon": {
                        this.#ctx.globalCompositeOperation = "source-over";

                        this.#ctx.beginPath();
                        for (let i = 0; i < shape.vertices.length; i++) {
                            const vertex = shape.vertices[i];
                            if (i === 0) {
                                this.#ctx.moveTo(vertex.x * this.#canvas.width, vertex.y * this.#canvas.height);
                            } else {
                                this.#ctx.lineTo(vertex.x * this.#canvas.width, vertex.y * this.#canvas.height);
                            }
                        }
                        this.#ctx.closePath();

                        switch (shape.subType) {
                            case "fill": {
                                this.#ctx.fillStyle = shape.color;
                                this.#ctx.fill();
                                break;
                            }
                            case "stroke": {
                                this.#ctx.strokeStyle = shape.color;
                                this.#ctx.lineWidth = shape.lineWidth;
                                this.#ctx.stroke();
                                break;
                            }
                            default: {
                                console.warn("Unknown drawboard shape subtype:", shape.subType);
                                break;
                            }
                        }
                        break;
                    }
                    default: {
                        console.warn("Unknown drawboard shape type:", shape.type);
                        break;
                    }
                }
            }

            this.#ctx.globalAlpha = 1;
            requestAnimationFrame(this.#draw);
        }

        setShapeSettings = ({ color = null, transparency = null, lineWidth = null, lifeMs = null, fadeMs = null, textAlign = null, fontStyle = null, fontFamily = null, fontSize = null } = {}) => {
            this.#color = color ?? this.#color;
            this.#transparency = transparency ?? this.#transparency;
            this.#lineWidth = (Number.isFinite(lineWidth) ? lineWidth : this.#lineWidth) >>> 0;
            this.#lifeMs = (Number.isFinite(lifeMs) ? lifeMs : this.#lifeMs) >>> 0;
            this.#fadeMs = (Number.isFinite(fadeMs) ? fadeMs : this.#fadeMs) >>> 0;
            this.#textAlign = textAlign ? (Object.values(Drawboard.TextAlign).includes(textAlign) ? textAlign : this.#textAlign) : this.#textAlign;
            this.#fontStyle = fontStyle ? fontStyle.filter(style => Drawboard.FontStyle.includes(style)) : this.#fontStyle;
            this.#fontFamily = fontFamily ? (Object.values(Drawboard.FontFamily).includes(fontFamily) ? fontFamily : this.#fontFamily) : this.#fontFamily;
            this.#fontSize = (Number.isFinite(fontSize) ? fontSize : this.#fontSize) >>> 0;
        }

        renderLine = ({ x1, y1, x2, y2, color, transparency, lineWidth, lifeMs, fadeMs, uuid = this.generateUUID(), owner = null } = {}) => {
            const shape = {
                type: "line",
                x1, y1,
                x2, y2,
                color,
                transparency: Math.clamp(0, transparency, 1),
                lineWidth,
                lifeMs,
                fadeMs,
                timestamp: Date.now(),
                uuid: uuid >>> 0,
                owner: owner || null
            };
            this.#shapeBuffer.push(shape);
            return uuid >>> 0;
        }


        drawLine = ({ x1, y1, x2, y2, color = null, transparency = null, lineWidth = null, lifeMs = null, fadeMs = null, chain = true } = {}) => {
            color = color ?? this.#color;
            transparency = transparency ?? this.#transparency;
            lineWidth = (Number.isFinite(lineWidth) ? lineWidth : this.#lineWidth) >>> 0;
            lifeMs = (Number.isFinite(lifeMs) ? lifeMs : this.#lifeMs) >>> 0;
            fadeMs = (Number.isFinite(fadeMs) ? fadeMs : this.#fadeMs) >>> 0;

            const nx1 = Math.clamp(0, Number(x1) || 0, 1);
            const ny1 = Math.clamp(0, Number(y1) || 0, 1);
            const nx2 = Math.clamp(0, Number(x2) || 0, 1);
            const ny2 = Math.clamp(0, Number(y2) || 0, 1);

            const uuid = this.generateUUID();

            this.renderLine({
                x1: nx1,
                y1: ny1,
                x2: nx2,
                y2: ny2,
                color: color,
                transparency: transparency,
                lineWidth: lineWidth,
                lifeMs: lifeMs,
                fadeMs: fadeMs,
                uuid: uuid,
                owner: MPP?.client?.participantId || null
            });

            const x1u = Math.round(nx1 * 65535) >>> 0;
            const y1u = Math.round(ny1 * 65535) >>> 0;
            const x2u = Math.round(nx2 * 65535) >>> 0;
            const y2u = Math.round(ny2 * 65535) >>> 0;

            if (chain) {
                if (!this.#localChainStarted) {
                    this.#pushOp({
                        op: 3,
                        color: color,
                        transparency: transparency,
                        lineWidth: lineWidth,
                        lifeMs: lifeMs,
                        fadeMs: fadeMs,
                        xu: x1u,
                        yu: y1u
                    });
                    this.#localChainStarted = true;
                }

                this.#pushOp({
                    op: 4,
                    entries: [{
                        x: x2u & 0xFFFF,
                        y: y2u & 0xFFFF,
                        uuid: uuid >>> 0
                    }]
                });
            } else {
                this.#pushOp({
                    op: 2,
                    color: color,
                    transparency: transparency,
                    lineWidth: lineWidth,
                    lifeMs: lifeMs,
                    fadeMs: fadeMs,
                    x1u: x1u & 0xFFFF,
                    y1u: y1u & 0xFFFF,
                    x2u: x2u & 0xFFFF,
                    y2u: y2u & 0xFFFF,
                    uuid: uuid >>> 0
                });
            }

            return uuid >>> 0;
        }

        drawLines = (segments = [], { color = null, transparency = null, lineWidth = null, lifeMs = null, fadeMs = null, chain = undefined } = {}) => {
            if (!Array.isArray(segments) || !segments.length) return [];

            const segs = segments.map(s => ({
                x1: Math.clamp(0, Number(s.x1) || 0, 1),
                y1: Math.clamp(0, Number(s.y1) || 0, 1),
                x2: Math.clamp(0, Number(s.x2) || 0, 1),
                y2: Math.clamp(0, Number(s.y2) || 0, 1),
                color: s.color ?? color,
                transparency: s.transparency ?? transparency,
                lineWidth: Number.isFinite(s.lineWidth) ? s.lineWidth : lineWidth,
                lifeMs: Number.isFinite(s.lifeMs) ? s.lifeMs : lifeMs,
                fadeMs: Number.isFinite(s.fadeMs) ? s.fadeMs : fadeMs,
            }));

            const eps = 1e-6;
            const autoChainable = segs.length > 1 && (() => {
                for (let i = 0; i < segs.length - 1; i++) {
                    const a = segs[i], b = segs[i + 1];
                    if (Math.abs(a.x2 - b.x1) > eps || Math.abs(a.y2 - b.y1) > eps) return false;
                }
                return true;
            })();

            // - if chain === true => force chaining
            // - if chain === false => never chain
            // - if chain === undefined => chain only when autoChainable
            const useChain = chain === true ? true : (chain === false ? false : autoChainable);

            if (useChain) {
                const first = segs[0];
                const xu = Math.round(first.x1 * 65535);
                const yu = Math.round(first.y1 * 65535);

                this.#pushOp({
                    op: 3,
                    color: first.color ?? this.#color,
                    transparency: first.transparency ?? this.#transparency,
                    lineWidth: first.lineWidth ?? this.#lineWidth,
                    lifeMs: first.lifeMs ?? this.#lifeMs,
                    fadeMs: first.fadeMs ?? this.#fadeMs,
                    xu: xu,
                    yu: yu
                });

                const entries = [];
                const uuids = [];

                for (const s of segs) {
                    const uuid = this.generateUUID() >>> 0;
                    uuids.push(uuid);

                    this.renderLine({
                        x1: s.x1,
                        y1: s.y1,
                        x2: s.x2,
                        y2: s.y2,
                        color: s.color ?? this.#color,
                        transparency: s.transparency ?? this.#transparency,
                        lineWidth: s.lineWidth ?? this.#lineWidth,
                        lifeMs: s.lifeMs ?? this.#lifeMs,
                        fadeMs: s.fadeMs ?? this.#fadeMs,
                        uuid: uuid,
                        owner: MPP?.client?.participantId || null
                    });

                    entries.push({
                        x: Math.round(s.x2 * 65535) & 0xFFFF,
                        y: Math.round(s.y2 * 65535) & 0xFFFF,
                        uuid: uuid >>> 0
                    });
                }

                this.#pushOp({
                    op: 4,
                    entries: entries
                });

                return uuids;
            }

            const results = [];
            for (const s of segs) {
                const id = this.drawLine({
                    x1: s.x1,
                    y1: s.y1,
                    x2: s.x2,
                    y2: s.y2,
                    color: s.color ?? color,
                    transparency: s.transparency ?? transparency,
                    lineWidth: s.lineWidth ?? lineWidth,
                    lifeMs: s.lifeMs ?? lifeMs,
                    fadeMs: s.fadeMs ?? fadeMs,
                    chain: false
                });
                results.push(id);
            }
            return results;
        }

        renderTriangle = ({ x1, y1, x2, y2, x3, y3, color, transparency, lifeMs, fadeMs, uuid = this.generateUUID(), owner = null } = {}) => {
            const shape = {
                type: "triangle",
                x1, y1,
                x2, y2,
                x3, y3,
                color,
                transparency: Math.clamp(0, transparency, 1),
                lifeMs,
                fadeMs,
                timestamp: Date.now(),
                uuid: uuid >>> 0,
                owner: owner || null
            };
            this.#shapeBuffer.push(shape);
            return uuid >>> 0;
        }

        drawTriangle = ({ x1, y1, x2, y2, x3, y3, color = null, transparency = null, lifeMs = null, fadeMs = null } = {}) => {
            color = color ?? this.#color;
            transparency = transparency ?? this.#transparency;
            lifeMs = (Number.isFinite(lifeMs) ? lifeMs : this.#lifeMs) >>> 0;
            fadeMs = (Number.isFinite(fadeMs) ? fadeMs : this.#fadeMs) >>> 0;

            const nx1 = Math.clamp(0, Number(x1) || 0, 1);
            const ny1 = Math.clamp(0, Number(y1) || 0, 1);
            const nx2 = Math.clamp(0, Number(x2) || 0, 1);
            const ny2 = Math.clamp(0, Number(y2) || 0, 1);
            const nx3 = Math.clamp(0, Number(x3) || 0, 1);
            const ny3 = Math.clamp(0, Number(y3) || 0, 1);

            const uuid = this.generateUUID();

            this.renderTriangle({
                x1: nx1,
                y1: ny1,
                x2: nx2,
                y2: ny2,
                x3: nx3,
                y3: ny3,
                color: color,
                transparency: transparency,
                lifeMs: lifeMs,
                fadeMs: fadeMs,
                uuid: uuid,
                owner: MPP?.client?.participantId || null
            });

            const x1u = Math.round(nx1 * 65535) >>> 0;
            const y1u = Math.round(ny1 * 65535) >>> 0;
            const x2u = Math.round(nx2 * 65535) >>> 0;
            const y2u = Math.round(ny2 * 65535) >>> 0;
            const x3u = Math.round(nx3 * 65535) >>> 0;
            const y3u = Math.round(ny3 * 65535) >>> 0;

            this.#pushOp({
                op: 5,
                color: color,
                transparency: transparency,
                lifeMs: lifeMs,
                fadeMs: fadeMs,
                x1u: x1u & 0xFFFF,
                y1u: y1u & 0xFFFF,
                x2u: x2u & 0xFFFF,
                y2u: y2u & 0xFFFF,
                x3u: x3u & 0xFFFF,
                y3u: y3u & 0xFFFF,
                uuid: uuid >>> 0
            });

            return uuid >>> 0;
        }

        drawTriangles = (triangles = [], { color = null, transparency = null, lifeMs = null, fadeMs = null } = {}) => {
            if (!Array.isArray(triangles) || !triangles.length) return [];

            const results = [];
            for (const t of triangles) {
                const id = this.drawTriangle({
                    x1: Math.clamp(0, Number(t.x1) || 0, 1),
                    y1: Math.clamp(0, Number(t.y1) || 0, 1),
                    x2: Math.clamp(0, Number(t.x2) || 0, 1),
                    y2: Math.clamp(0, Number(t.y2) || 0, 1),
                    x3: Math.clamp(0, Number(t.x3) || 0, 1),
                    y3: Math.clamp(0, Number(t.y3) || 0, 1),
                    color: t.color ?? color,
                    transparency: t.transparency ?? transparency,
                    lifeMs: Number.isFinite(t.lifeMs) ? t.lifeMs : lifeMs,
                    fadeMs: Number.isFinite(t.fadeMs) ? t.fadeMs : fadeMs
                });
                results.push(id);
            }
            return results;
        }

        renderEllipse = ({ cx, cy, rx, ry, color, transparency, lineWidth, lifeMs, fadeMs, subType = "fill", uuid = this.generateUUID(), owner = null } = {}) => {
            const shape = {
                type: "ellipse",
                subType,
                cx, cy,
                rx, ry,
                color,
                transparency: Math.clamp(0, transparency, 1),
                lineWidth,
                lifeMs,
                fadeMs,
                timestamp: Date.now(),
                uuid: uuid >>> 0,
                owner: owner || null
            };
            this.#shapeBuffer.push(shape);
            return uuid >>> 0;
        }

        drawEllipse = ({ cx, cy, rx, ry, color = null, transparency = null, lineWidth = null, lifeMs = null, fadeMs = null, fill = true } = {}) => {
            color = color ?? this.#color;
            transparency = transparency ?? this.#transparency;
            lineWidth = (Number.isFinite(lineWidth) ? lineWidth : this.#lineWidth) >>> 0;
            lifeMs = (Number.isFinite(lifeMs) ? lifeMs : this.#lifeMs) >>> 0;
            fadeMs = (Number.isFinite(fadeMs) ? fadeMs : this.#fadeMs) >>> 0;

            const ncx = Math.clamp(0, Number(cx) || 0, 1);
            const ncy = Math.clamp(0, Number(cy) || 0, 1);
            const nrx = Math.clamp(0, Number(rx) || 0, 1);
            const nry = Math.clamp(0, Number(ry) || 0, 1);

            const uuid = this.generateUUID();

            this.renderEllipse({
                cx: ncx,
                cy: ncy,
                rx: nrx,
                ry: nry,
                color: color,
                transparency: transparency,
                lineWidth: lineWidth,
                lifeMs: lifeMs,
                fadeMs: fadeMs,
                subType: fill ? "fill" : "stroke",
                uuid: uuid,
                owner: MPP?.client?.participantId || null
            });

            const cxu = Math.round(ncx * 65535) >>> 0;
            const cyu = Math.round(ncy * 65535) >>> 0;
            const rxu = Math.round(nrx * 65535) >>> 0;
            const ryu = Math.round(nry * 65535) >>> 0;

            if (fill) {
                this.#pushOp({
                    op: 7,
                    color: color,
                    transparency: transparency,
                    lifeMs: lifeMs,
                    fadeMs: fadeMs,
                    cxu: cxu & 0xFFFF,
                    cyu: cyu & 0xFFFF,
                    rxu: rxu & 0xFFFF,
                    ryu: ryu & 0xFFFF,
                    uuid: uuid >>> 0
                });
            } else {
                this.#pushOp({
                    op: 6,
                    color: color,
                    transparency: transparency,
                    lineWidth: lineWidth,
                    lifeMs: lifeMs,
                    fadeMs: fadeMs,
                    cxu: cxu & 0xFFFF,
                    cyu: cyu & 0xFFFF,
                    rxu: rxu & 0xFFFF,
                    ryu: ryu & 0xFFFF,
                    uuid: uuid >>> 0
                });
            }

            return uuid >>> 0;
        }

        drawEllipses = (ellipses = [], { color = null, transparency = null, lineWidth = null, lifeMs = null, fadeMs = null } = {}) => {
            if (!Array.isArray(ellipses) || !ellipses.length) return [];
            const results = [];
            for (const e of ellipses) {
                const id = this.drawEllipse({
                    cx: Math.clamp(0, Number(e.cx) || 0, 1),
                    cy: Math.clamp(0, Number(e.cy) || 0, 1),
                    rx: Math.clamp(0, Number(e.rx) || 0, 1),
                    ry: Math.clamp(0, Number(e.ry) || 0, 1),
                    color: e.color ?? color,
                    transparency: e.transparency ?? transparency,
                    lineWidth: Number.isFinite(e.lineWidth) ? e.lineWidth : lineWidth,
                    lifeMs: Number.isFinite(e.lifeMs) ? e.lifeMs : lifeMs,
                    fadeMs: Number.isFinite(e.fadeMs) ? e.fadeMs : fadeMs,
                    fill: e.fill
                });
                results.push(id);
            }
            return results;
        }

        renderText = ({ x, y, text, color, transparency, fontSize, lifeMs, fadeMs, options, uuid = this.generateUUID(), owner = null } = {}) => {
            const shape = {
                type: "text",
                x, y,
                text: String(text),
                color,
                transparency: Math.clamp(0, transparency, 1),
                fontSize,
                lifeMs,
                fadeMs,
                options: options & 0xFF,
                timestamp: Date.now(),
                uuid: uuid >>> 0,
                owner: owner || null
            };
            this.#shapeBuffer.push(shape);
            return uuid >>> 0;
        }

        drawText = ({ x, y, text, color = null, transparency = null, fontSize = null, lineWidth = null, lifeMs = null, fadeMs = null, textAlign = null, fontStyle = [], fontFamily = null } = {}) => {
            color = color ?? this.#color;
            transparency = transparency ?? this.#transparency;
            fontSize = (Number.isFinite(fontSize) ? fontSize : this.#fontSize) >>> 0;
            lineWidth = (Number.isFinite(lineWidth) ? lineWidth : this.#lineWidth) >>> 0;
            lifeMs = (Number.isFinite(lifeMs) ? lifeMs : this.#lifeMs) >>> 0;
            fadeMs = (Number.isFinite(fadeMs) ? fadeMs : this.#fadeMs) >>> 0;

            let options = 0;
            options |= Object.values(Drawboard.TextAlign).indexOf(textAlign ?? Drawboard.TextAlign.LEFT) & 0x03;
            options |= (fontStyle.includes("bold") ? 1 : 0) << 2;
            options |= (fontStyle.includes("italic") ? 1 : 0) << 3;
            options |= (fontStyle.includes("underline") ? 1 : 0) << 4;
            options |= (fontStyle.includes("line-through") ? 1 : 0) << 5;
            options |= (Object.values(Drawboard.FontFamily).indexOf(fontFamily ?? Drawboard.FontFamily.SANS_SERIF) & 0x03) << 6;

            const nx = Math.clamp(0, Number(x) || 0, 1);
            const ny = Math.clamp(0, Number(y) || 0, 1);

            const uuid = this.generateUUID();

            this.renderText({
                x: nx,
                y: ny,
                text,
                color,
                transparency,
                fontSize,
                lineWidth,
                lifeMs,
                fadeMs,
                options,
                uuid,
                owner: MPP?.client?.participantId || null
            });

            const xu = Math.round(nx * 65535) >>> 0;
            const yu = Math.round(ny * 65535) >>> 0;

            this.#pushOp({
                op: 8,
                color: color,
                transparency: transparency,
                fontSize: fontSize,
                lifeMs: lifeMs,
                fadeMs: fadeMs,
                xu: xu & 0xFFFF,
                yu: yu & 0xFFFF,
                text: text,
                options: options & 0xFF,
                uuid: uuid >>> 0
            });

            return uuid >>> 0;
        }

        renderPolygon = ({ vertices = [], color, transparency, lineWidth, lifeMs, fadeMs, subType = "fill", uuid = this.generateUUID(), owner = null } = {}) => {
            const shape = {
                type: "polygon",
                subType,
                vertices,
                color,
                transparency: Math.clamp(0, transparency, 1),
                lineWidth,
                lifeMs,
                fadeMs,
                timestamp: Date.now(),
                uuid: uuid >>> 0,
                owner: owner || null
            };
            this.#shapeBuffer.push(shape);
            return uuid >>> 0;
        }

        drawPolygon = ({ vertices = [], color = null, transparency = null, lineWidth = null, lifeMs = null, fadeMs = null, fill = true } = {}) => {
            color = color ?? this.#color;
            transparency = transparency ?? this.#transparency;
            lineWidth = (Number.isFinite(lineWidth) ? lineWidth : this.#lineWidth) >>> 0;
            lifeMs = (Number.isFinite(lifeMs) ? lifeMs : this.#lifeMs) >>> 0;
            fadeMs = (Number.isFinite(fadeMs) ? fadeMs : this.#fadeMs) >>> 0;

            const sv = Array.isArray(vertices) ? vertices.map(v => {
                const nx = Number(v?.x) || 0;
                const ny = Number(v?.y) || 0;
                return {
                    x: Math.clamp(0, nx, 1),
                    y: Math.clamp(0, ny, 1)
                };
            }) : [];

            const uuid = this.generateUUID();

            this.renderPolygon({
                vertices: sv,
                color: color,
                transparency: transparency,
                lineWidth: lineWidth,
                lifeMs: lifeMs,
                fadeMs: fadeMs,
                subType: fill ? "fill" : "stroke",
                uuid: uuid,
                owner: MPP?.client?.participantId || null
            });

            const vu = sv.map(v => ({
                xu: (Math.round(v.x * 65535) >>> 0) & 0xFFFF,
                yu: (Math.round(v.y * 65535) >>> 0) & 0xFFFF
            }));

            if (fill) {
                this.#pushOp({
                    op: 10,
                    color: color,
                    transparency: transparency,
                    lifeMs: lifeMs,
                    fadeMs: fadeMs,
                    vertices: vu,
                    uuid: uuid >>> 0
                });
            } else {
                this.#pushOp({
                    op: 9,
                    color: color,
                    transparency: transparency,
                    lineWidth: lineWidth,
                    lifeMs: lifeMs,
                    fadeMs: fadeMs,
                    vertices: vu,
                    uuid: uuid >>> 0
                });
            }

            return uuid >>> 0;
        }

        drawPolygons = (polygons = [], { color = null, transparency = null, lineWidth = null, lifeMs = null, fadeMs = null } = {}) => {
            if (!Array.isArray(polygons) || !polygons.length) return [];
            const results = [];
            for (const p of polygons) {
                const id = this.drawPolygon({
                    vertices: p.vertices?.map(v => ({
                        x: Math.clamp(0, Number(v.x) || 0, 1),
                        y: Math.clamp(0, Number(v.y) || 0, 1)
                    })),
                    color: p.color ?? color,
                    transparency: p.transparency ?? transparency,
                    lineWidth: Number.isFinite(p.lineWidth) ? p.lineWidth : lineWidth,
                    lifeMs: Number.isFinite(p.lifeMs) ? p.lifeMs : lifeMs,
                    fadeMs: Number.isFinite(p.fadeMs) ? p.fadeMs : fadeMs,
                    fill: p.fill
                });
                results.push(id);
            }
            return results;
        }

        renderErase = ({ x, y, radius } = {}) => {
            if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(radius)) return [];
            const removed = [];
            for (let i = this.#shapeBuffer.length - 1; i >= 0; i--) {
                const shape = this.#shapeBuffer[i];

                switch (shape.type) {
                    case "line": {
                        const d = this.#pointToSegmentDistance(x, y, shape.x1, shape.y1, shape.x2, shape.y2);
                        if (d <= radius) {
                            if (shape.uuid) removed.push(shape.uuid);
                            this.#shapeBuffer.splice(i, 1);
                        }
                        break;
                    }
                    case "triangle": {
                        const inside = this.#pointInTriangle(x, y, shape.x1, shape.y1, shape.x2, shape.y2, shape.x3, shape.y3);
                        if (inside) {
                            if (shape.uuid) removed.push(shape.uuid);
                            this.#shapeBuffer.splice(i, 1);
                        } else {
                            const d1 = this.#pointToSegmentDistance(x, y, shape.x1, shape.y1, shape.x2, shape.y2);
                            const d2 = this.#pointToSegmentDistance(x, y, shape.x2, shape.y2, shape.x3, shape.y3);
                            const d3 = this.#pointToSegmentDistance(x, y, shape.x3, shape.y3, shape.x1, shape.y1);
                            const d = Math.min(d1, d2, d3);
                            if (d <= radius) {
                                if (shape.uuid) removed.push(shape.uuid);
                                this.#shapeBuffer.splice(i, 1);
                            }
                        }
                        break;
                    }
                    case "ellipse": {
                        const cx = shape.cx;
                        const cy = shape.cy;
                        const rx = shape.rx;
                        const ry = shape.ry;

                        switch (shape.subType) {
                            case "fill": {
                                const vx = (x - cx) / rx;
                                const vy = (y - cy) / ry;
                                if ((vx * vx + vy * vy) <= 1) {
                                    if (shape.uuid) removed.push(shape.uuid);
                                    this.#shapeBuffer.splice(i, 1);
                                } else {
                                    const distToBoundary = Math.abs(Math.sqrt(vx * vx + vy * vy) - 1) * Math.max(rx, ry);
                                    if (distToBoundary <= radius) {
                                        if (shape.uuid) removed.push(shape.uuid);
                                        this.#shapeBuffer.splice(i, 1);
                                    }
                                }
                                break;
                            }
                            case "stroke": {
                                const vx = (x - cx) / rx;
                                const vy = (y - cy) / ry;
                                const norm = Math.sqrt(vx * vx + vy * vy);
                                const distToBoundary = Math.abs(norm - 1) * Math.max(rx, ry);
                                if (distToBoundary <= radius) {
                                    if (shape.uuid) removed.push(shape.uuid);
                                    this.#shapeBuffer.splice(i, 1);
                                }
                                break;
                            }
                            default: {
                                console.warn("Unknown drawboard shape subtype:", shape.subType);
                                break;
                            }
                        }
                        break;
                    }
                    case "text": {
                        const cx = x * this.#canvas.width;
                        const cy = y * this.#canvas.height;
                        const radiusPx = radius * Math.max(this.#canvas.width, this.#canvas.height);

                        const opts = shape.options || 0;
                        const align = opts & 0x03;
                        const bold = !!(opts & 0x04);
                        const italic = !!(opts & 0x08);
                        // const underline = !!(opts & 0x10); // not needed for measurement
                        // const lineThrough = !!(opts & 0x20);
                        const fontIndex = (opts >> 6) & 0x03;

                        const families = [
                            Drawboard.FontFamily.SANS_SERIF,
                            Drawboard.FontFamily.SERIF,
                            Drawboard.FontFamily.MONOSPACE,
                            Drawboard.FontFamily.CURSIVE
                        ];
                        const family = families[fontIndex] || Drawboard.FontFamily.SANS_SERIF;
                        const style = (italic ? "italic " : "") + (bold ? "bold " : "");
                        const fontSize = Math.max(1, Number(shape.fontSize) || this.#fontSize || 12);

                        this.#ctx.save();
                        this.#ctx.font = `${style}${fontSize}px ${family}`;
                        this.#ctx.textBaseline = "top";

                        const metrics = this.#ctx.measureText(shape.text || "");
                        const textWidth = metrics.width || 0;

                        let textHeight = fontSize;
                        if (typeof metrics.actualBoundingBoxAscent === "number" && typeof metrics.actualBoundingBoxDescent === "number") {
                            textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
                            if (!(Number.isFinite(textHeight)) || textHeight <= 0) textHeight = fontSize;
                        }

                        const px = shape.x * this.#canvas.width;
                        const py = shape.y * this.#canvas.height;
                        let startX = px;
                        if (align === 1) startX = px - textWidth;
                        else if (align === 2) startX = px - textWidth / 2;

                        const rect = {
                            x: startX,
                            y: py,
                            w: textWidth,
                            h: textHeight
                        };

                        const nearestX = Math.max(rect.x, Math.min(cx, rect.x + rect.w));
                        const nearestY = Math.max(rect.y, Math.min(cy, rect.y + rect.h));
                        const dx = nearestX - cx;
                        const dy = nearestY - cy;
                        const distSq = dx * dx + dy * dy;

                        this.#ctx.restore();

                        if (distSq <= radiusPx * radiusPx) {
                            if (shape.uuid) removed.push(shape.uuid);
                            this.#shapeBuffer.splice(i, 1);
                        }
                        break;
                    }
                    case "polygon": {
                        const verts = shape.vertices;
                        if (!Array.isArray(verts) || verts.length < 3) break;

                        switch (shape.subType) {
                            case "fill": {
                                const inside = this.#pointInPolygon(x, y, verts);
                                if (inside) {
                                    if (shape.uuid) removed.push(shape.uuid);
                                    this.#shapeBuffer.splice(i, 1);
                                    break;
                                }

                                let minDist = Infinity;
                                for (let j = 0; j < verts.length; j++) {
                                    const v1 = verts[j];
                                    const v2 = verts[(j + 1) % verts.length];
                                    const d = this.#pointToSegmentDistance(x, y, v1.x, v1.y, v2.x, v2.y);
                                    if (d < minDist) minDist = d;
                                }
                                if (minDist <= radius) {
                                    if (shape.uuid) removed.push(shape.uuid);
                                    this.#shapeBuffer.splice(i, 1);
                                }
                                break;
                            }
                            case "stroke": {
                                let minDist = Infinity;
                                for (let j = 0; j < verts.length; j++) {
                                    const v1 = verts[j];
                                    const v2 = verts[(j + 1) % verts.length];
                                    const d = this.#pointToSegmentDistance(x, y, v1.x, v1.y, v2.x, v2.y);
                                    if (d < minDist) minDist = d;
                                }
                                if (minDist <= radius) {
                                    if (shape.uuid) removed.push(shape.uuid);
                                    this.#shapeBuffer.splice(i, 1);
                                }
                                break;
                            }
                            default: {
                                console.warn("Unknown drawboard shape subtype:", shape.subType);
                                break;
                            }
                        }
                        break;
                    }
                    default: {
                        console.warn("Unknown drawboard shape type:", shape.type);
                        break;
                    }
                }
            }

            const removedUUIDs = Array.from(new Set(removed));
            return removedUUIDs;
        }

        erase = ({ x, y, radius } = {}) => {
            const removedUUIDs = this.renderErase({ x, y, radius });
            return removedUUIDs;
        }

        eraseShape = ({ uuid } = {}) => {
            if (uuid == null) return [];
            const u = Number(uuid) >>> 0;

            const removed = this.#removeShapesByUUIDs([u]);
            if (removed && removed.length) {
                this.#pushOp({ op: 1, uuids: removed.map(n => Number(n) >>> 0) });
            } else {
                this.#pushOp({ op: 1, uuids: [u] });
            }
            return removed;
        }

        eraseShapes = (uuids = []) => {
            if (!Array.isArray(uuids) || !uuids.length) return [];
            const clean = Array.from(new Set(uuids.map(n => Number(n) >>> 0)));
            const removed = this.#removeShapesByUUIDs(clean);
            if (removed && removed.length) {
                this.#pushOp({ op: 1, uuids: removed.map(n => Number(n) >>> 0) });
            } else {
                this.#pushOp({ op: 1, uuids: clean });
            }
            return removed;
        }

        eraseAll = () => {
            const ownerId = MPP?.client?.participantId || null;
            const ownerStr = ownerId !== null ? String(ownerId) : null;
            if (ownerStr !== null) {
                this.#removeLinesByOwner(ownerStr);
            } else {
                this.#shapeBuffer.length = 0;
            }
            this.#pushOp({ op: 0 });
        }

        handleIncomingData = (packet) => {
            if (!packet?.data?.drawboard) return;
            const payload = atob(packet.data.drawboard);

            try {
                const bytes = new Array(payload.length);
                for (let i = 0; i < payload.length; i++) bytes[i] = payload.charCodeAt(i);

                const state = { i: 0 };
                const opCount = this.#readULEB128(bytes, state);

                const senderId = (packet && packet.p) ? String(packet.p) : null;

                for (let opIndex = 0; opIndex < opCount; opIndex++) {
                    const type = this.#readUint8(bytes, state);

                    switch (type) {
                        case 0: {
                            if (!senderId) {
                                console.warn("Clear user received but no sender provided.");
                            } else {
                                this.#removeLinesByOwner(senderId);
                            }
                            break;
                        }
                        case 1: {
                            const len = this.#readULEB128(bytes, state);
                            const uuids = [];
                            for (let k = 0; k < len; k++) {
                                const u = this.#readUint32(bytes, state);
                                uuids.push(u >>> 0);
                            }
                            this.#removeShapesByUUIDs(uuids);
                            break;
                        }
                        case 2: {
                            const color = this.#readColor(bytes, state);
                            const transparency = Math.clamp(0, this.#readUint8(bytes, state) / 255, 1);
                            const lineWidth = this.#readULEB128(bytes, state);
                            const lifeMs = this.#readULEB128(bytes, state);
                            const fadeMs = this.#readULEB128(bytes, state);
                            const x1u = this.#readUint16(bytes, state);
                            const y1u = this.#readUint16(bytes, state);
                            const x2u = this.#readUint16(bytes, state);
                            const y2u = this.#readUint16(bytes, state);
                            const uuid = this.#readUint32(bytes, state);

                            const x1 = Math.clamp(0, x1u / 65535, 1);
                            const y1 = Math.clamp(0, y1u / 65535, 1);
                            const x2 = Math.clamp(0, x2u / 65535, 1);
                            const y2 = Math.clamp(0, y2u / 65535, 1);

                            this.renderLine({
                                x1, y1, x2, y2,
                                color,
                                transparency,
                                lineWidth,
                                lifeMs: lifeMs,
                                fadeMs: fadeMs,
                                uuid: uuid >>> 0,
                                owner: senderId
                            });
                            break;
                        }
                        case 3: {
                            const color = this.#readColor(bytes, state);
                            const transparency = Math.clamp(0, this.#readUint8(bytes, state) / 255, 1);
                            const lineWidth = this.#readULEB128(bytes, state);
                            const lifeMs = this.#readULEB128(bytes, state);
                            const fadeMs = this.#readULEB128(bytes, state);
                            const xu = this.#readUint16(bytes, state);
                            const yu = this.#readUint16(bytes, state);
                            const entry = {
                                x: xu >>> 0,
                                y: yu >>> 0,
                                color,
                                transparency,
                                lineWidth,
                                lifeMs,
                                fadeMs
                            };
                            if (senderId) this.#chains.set(senderId, entry);
                            break;
                        }
                        case 4: {
                            const len = this.#readULEB128(bytes, state);
                            if (!senderId) {
                                for (let k = 0; k < len; k++) {
                                    const xu = this.#readUint16(bytes, state);
                                    const yu = this.#readUint16(bytes, state);
                                    const uuid = this.#readUint32(bytes, state);
                                    // no sender ... ignore for now
                                }
                            } else {
                                let chain = this.#chains.get(senderId);
                                for (let k = 0; k < len; k++) {
                                    const xu = this.#readUint16(bytes, state);
                                    const yu = this.#readUint16(bytes, state);
                                    const uuid = this.#readUint32(bytes, state);

                                    const x = xu >>> 0;
                                    const y = yu >>> 0;
                                    if (!chain) {
                                        chain = {
                                            x,
                                            y,
                                            color: "#000000",
                                            transparency: 1,
                                            lineWidth: 3,
                                            lifeMs: 5000,
                                            fadeMs: 3000
                                        };
                                        this.#chains.set(senderId, chain);
                                        continue;
                                    }

                                    const x1n = Math.clamp(0, chain.x / 65535, 1);
                                    const y1n = Math.clamp(0, chain.y / 65535, 1);
                                    const x2n = Math.clamp(0, x / 65535, 1);
                                    const y2n = Math.clamp(0, y / 65535, 1);

                                    this.renderLine({
                                        x1: x1n,
                                        y1: y1n,
                                        x2: x2n,
                                        y2: y2n,
                                        color: chain.color,
                                        transparency: chain.transparency,
                                        lineWidth: chain.lineWidth,
                                        lifeMs: chain.lifeMs,
                                        fadeMs: chain.fadeMs,
                                        uuid: uuid >>> 0,
                                        owner: senderId
                                    });

                                    chain.x = x;
                                    chain.y = y;
                                }
                                this.#chains.set(senderId, chain);
                            }
                            break;
                        }
                        case 5: {
                            const color = this.#readColor(bytes, state);
                            const transparency = Math.clamp(0, this.#readUint8(bytes, state) / 255, 1);
                            const lifeMs = this.#readULEB128(bytes, state);
                            const fadeMs = this.#readULEB128(bytes, state);
                            const x1u = this.#readUint16(bytes, state);
                            const y1u = this.#readUint16(bytes, state);
                            const x2u = this.#readUint16(bytes, state);
                            const y2u = this.#readUint16(bytes, state);
                            const x3u = this.#readUint16(bytes, state);
                            const y3u = this.#readUint16(bytes, state);
                            const uuid = this.#readUint32(bytes, state);

                            const x1 = Math.clamp(0, x1u / 65535, 1);
                            const y1 = Math.clamp(0, y1u / 65535, 1);
                            const x2 = Math.clamp(0, x2u / 65535, 1);
                            const y2 = Math.clamp(0, y2u / 65535, 1);
                            const x3 = Math.clamp(0, x3u / 65535, 1);
                            const y3 = Math.clamp(0, y3u / 65535, 1);

                            this.renderTriangle({
                                x1, y1, x2, y2, x3, y3,
                                color,
                                transparency,
                                lifeMs: lifeMs,
                                fadeMs: fadeMs,
                                uuid: uuid >>> 0,
                                owner: senderId
                            });
                            break;
                        }
                        case 6: {
                            const color = this.#readColor(bytes, state);
                            const transparency = Math.clamp(0, this.#readUint8(bytes, state) / 255, 1);
                            const lineWidth = this.#readULEB128(bytes, state);
                            const lifeMs = this.#readULEB128(bytes, state);
                            const fadeMs = this.#readULEB128(bytes, state);
                            const cxu = this.#readUint16(bytes, state);
                            const cyu = this.#readUint16(bytes, state);
                            const rxu = this.#readUint16(bytes, state);
                            const ryu = this.#readUint16(bytes, state);
                            const uuid = this.#readUint32(bytes, state);

                            const cx = Math.clamp(0, cxu / 65535, 1);
                            const cy = Math.clamp(0, cyu / 65535, 1);
                            const rx = Math.clamp(0, rxu / 65535, 1);
                            const ry = Math.clamp(0, ryu / 65535, 1);

                            this.renderEllipse({
                                cx, cy,
                                rx, ry,
                                color,
                                transparency,
                                lineWidth,
                                lifeMs: lifeMs,
                                fadeMs: fadeMs,
                                subType: "stroke",
                                uuid: uuid >>> 0,
                                owner: senderId
                            });
                            break;
                        }
                        case 7: {
                            const color = this.#readColor(bytes, state);
                            const transparency = Math.clamp(0, this.#readUint8(bytes, state) / 255, 1);
                            const lifeMs = this.#readULEB128(bytes, state);
                            const fadeMs = this.#readULEB128(bytes, state);
                            const cxu = this.#readUint16(bytes, state);
                            const cyu = this.#readUint16(bytes, state);
                            const rxu = this.#readUint16(bytes, state);
                            const ryu = this.#readUint16(bytes, state);
                            const uuid = this.#readUint32(bytes, state);

                            const cx = Math.clamp(0, cxu / 65535, 1);
                            const cy = Math.clamp(0, cyu / 65535, 1);
                            const rx = Math.clamp(0, rxu / 65535, 1);
                            const ry = Math.clamp(0, ryu / 65535, 1);

                            this.renderEllipse({
                                cx, cy,
                                rx, ry,
                                color,
                                transparency,
                                lineWidth: 1,
                                lifeMs: lifeMs,
                                fadeMs: fadeMs,
                                subType: "fill",
                                uuid: uuid >>> 0,
                                owner: senderId
                            });
                            break;
                        }
                        case 8: {
                            const color = this.#readColor(bytes, state);
                            const transparency = Math.clamp(0, this.#readUint8(bytes, state) / 255, 1);
                            const fontSize = this.#readULEB128(bytes, state);
                            const lifeMs = this.#readULEB128(bytes, state);
                            const fadeMs = this.#readULEB128(bytes, state);
                            const xu = this.#readUint16(bytes, state);
                            const yu = this.#readUint16(bytes, state);
                            const text = this.#readString(bytes, state);
                            const options = this.#readUint8(bytes, state);
                            const uuid = this.#readUint32(bytes, state);

                            const x = Math.clamp(0, xu / 65535, 1);
                            const y = Math.clamp(0, yu / 65535, 1);

                            this.renderText({
                                x, y,
                                text,
                                color,
                                transparency,
                                fontSize,
                                lineWidth: 1,
                                lifeMs: lifeMs,
                                fadeMs: fadeMs,
                                options,
                                uuid: uuid >>> 0,
                                owner: senderId
                            });
                            break;
                        }
                        case 9: {
                            const color = this.#readColor(bytes, state);
                            const transparency = Math.clamp(0, this.#readUint8(bytes, state) / 255, 1);
                            const lineWidth = this.#readULEB128(bytes, state);
                            const lifeMs = this.#readULEB128(bytes, state);
                            const fadeMs = this.#readULEB128(bytes, state);
                            const len = this.#readULEB128(bytes, state);

                            const vertices = [];
                            for (let i = 0; i < len; i++) {
                                const xu = this.#readUint16(bytes, state);
                                const yu = this.#readUint16(bytes, state);
                                const x = Math.clamp(0, xu / 65535, 1);
                                const y = Math.clamp(0, yu / 65535, 1);

                                vertices.push({ x, y });
                            }
                            const uuid = this.#readUint32(bytes, state);

                            this.renderPolygon({
                                vertices,
                                color,
                                transparency,
                                lineWidth,
                                lifeMs: lifeMs,
                                fadeMs: fadeMs,
                                subType: "stroke",
                                uuid: uuid >>> 0,
                                owner: senderId
                            });
                            break;
                        }
                        case 10: {
                            const color = this.#readColor(bytes, state);
                            const transparency = Math.clamp(0, this.#readUint8(bytes, state) / 255, 1);
                            const lifeMs = this.#readULEB128(bytes, state);
                            const fadeMs = this.#readULEB128(bytes, state);
                            const len = this.#readULEB128(bytes, state);

                            const vertices = [];
                            for (let i = 0; i < len; i++) {
                                const xu = this.#readUint16(bytes, state);
                                const yu = this.#readUint16(bytes, state);
                                const x = Math.clamp(0, xu / 65535, 1);
                                const y = Math.clamp(0, yu / 65535, 1);

                                vertices.push({ x, y });
                            }
                            const uuid = this.#readUint32(bytes, state);

                            this.renderPolygon({
                                vertices,
                                color,
                                transparency,
                                lifeMs: lifeMs,
                                fadeMs: fadeMs,
                                subType: "fill",
                                uuid: uuid >>> 0,
                                owner: senderId
                            });
                            break;
                        }
                        default: {
                            console.warn("Unknown drawboard op code:", type);
                            break;
                        }
                    }
                }
            } catch (err) {
                console.warn("Failed to parse incoming drawboard payload:", err);
            }
        }
    }

    async function run() {
        MPP.Drawboard = Drawboard;
        MPP.drawboard = new Drawboard();

        MPP.client.sendArray([{
            m: "+custom"
        }]);

        if (MPP?.client?.on) {
            MPP.client.on("custom", (packet) => {
                if (!packet || !packet.data) return;
                if (packet.data.drawboard) {
                    MPP.drawboard.handleIncomingData(packet);
                }
            });
        }
    }

    function check() {
        if (!Drawboard.connected) {
            return setTimeout(() => {
                check();
            }, 200);
        }

        run();
    }

    check();
})();