Adding multiplayer cursors to Profectus

This is how you can have a board where people’s cursors appear on other players’ screens. This can add a sense of togetherness that can add to the experience of some games. By default the code here will also allow users to have a temporary message next to their cursor that mimics figma’s cursors. It also adds a option to the settings modal to hide the cursors.

Server

I have a server running on https://cursors.incremental.social you can use, or you can host it yourself using the source code, available at cursors-server.

Client

First, since we’re using websockets make sure to get the websockets file from this forum post.

There are three files you need to implement the cursors themselves. The feature file, the Cursors vue component to add to your board, and the MessageInput to display at the bottom. Here are those files, which should all go in a “cursors” folder in src/features:

cursors.tsx
import Toggle from "components/fields/Toggle.vue";
import Board from "game/boards/Board.vue";
import { globalBus } from "game/events";
import settings, { registerSettingField } from "game/settings";
import { Socket } from "game/websocket";
import { Ref, ref, watch } from "vue";

export interface ClientSocketEvents {
    set_cursor_position: (
        id: string,
        position: {
            x: number;
            y: number;
        }
    ) => void;
    set_cursor_message: (id: string, message: string) => void;
    clear_cursors: () => void;
}

export interface ServerSocketEvents {
    set_cursor_position: (position: { x: number; y: number }) => void;
    set_cursor_message: (message: string) => void;
    change_room: (room: string) => void;
}

// Hash 'string' to a small number (digits default: 6)
// source: https://gist.github.com/jedp/3166329
function hash(string: string, digits: number = 6) {
    const m = Math.pow(10, digits + 1) - 1;
    const phi = Math.pow(10, digits) / 2 - 1;
    let n = 0;
    for (let i = 0; i < string.length; i++) {
        n = (n + phi * string.charCodeAt(i)) % m;
    }
    return n;
}

function calculateColor(id: string) {
    const h = hash(id, 5); // 6 digits
    let a = h % 1000;
    let b = Math.floor(h / 1000);
    // map a and b from 0-999 to -125 to 125
    a = a / 4 - 125;
    b = b / 4 - 125;
    return `lab(50 ${a} ${b})`;
}

/** Creates a cursors object that will be automatically populated and updated with the cursors provided from the socket. */
export function createCursors(
    socket: Socket<ClientSocketEvents, ServerSocketEvents>,
    boardRef: Ref<InstanceType<typeof Board> | undefined>
) {
    const cursors = ref<
        Record<
            string,
            {
                x: number;
                y: number;
                message?: string;
                timestamp: number;
                color: string;
            }
        >
    >({});

    socket.on("set_cursor_position", (id, position) => {
        cursors.value[id] = {
            x: position.x,
            y: position.y,
            message: cursors.value[id]?.message,
            timestamp: Date.now(),
            color: cursors.value[id]?.color ?? calculateColor(id)
        };
    });

    socket.on("set_cursor_message", (id, message) => {
        if (id in cursors.value) {
            cursors.value[id].message = message;
            cursors.value[id].timestamp = Date.now();
        }
    });

    socket.on("clear_cursors", () => {
        cursors.value = {};
    });

    // Clear out cursors that are inactive for >10s
    setInterval(() => {
        const now = Date.now();
        for (const id in cursors.value) {
            if (now - cursors.value[id].timestamp > 10000) {
                delete cursors.value[id];
            }
        }
    }, 1000);

    // Setup message to emit set_cursor_message when it updates, and clear after not updating for 5s
    const message = ref("");
    let timeoutId: NodeJS.Timeout;
    watch(message, m => {
        socket.emit("set_cursor_message", m);
        if (timeoutId != null) {
            clearInterval(timeoutId);
        }
        if (m) {
            timeoutId = setTimeout(() => {
                message.value = "";
            }, 10000);
        }
    });

    function updatePosition(e: MouseEvent | TouchEvent) {
        let position;
        if ("touches" in e) {
            const { clientX, clientY } = e.touches[0];
            position = { x: clientX, y: clientY };
        } else {
            const { clientX, clientY } = e;
            position = { x: clientX, y: clientY };
        }

        // Get the current position of the board in terms of client position/size
        const stageRect = boardRef.value?.stage?.scene?.parentElement?.getBoundingClientRect() ?? {
            x: 0,
            width: 0,
            y: 0
        };
        // Subtract the origin from the position, so it's now the distance from the top center
        position.x -= stageRect.x;
        position.y -= stageRect.y;

        // Get the current panzoom transform
        const { x, y, scale } = boardRef.value?.panZoomInstance?.getTransform() ?? {
            x: 0,
            y: 0,
            scale: 1
        };
        // Add the current pan amount
        position.x -= x;
        position.y -= y;
        // Divide the distance by the zoom level
        position.x /= scale;
        position.y /= scale;
        // x is weird, and needs us to subtract half the board width at the end to be centered
        position.x -= stageRect.width / 2;
        socket.emit("set_cursor_position", position);
    }

    return {
        cursors,
        message,
        updatePosition
    };
}

declare module "game/settings" {
    interface Settings {
        hideCursors: boolean;
    }
}

globalBus.on("loadSettings", settings => {
    settings.hideCursors ??= false;
});

globalBus.on("setupVue", () =>
    registerSettingField(() => (
        <Toggle
            title={<span class="option-title">Hide other users' cursors</span>}
            onUpdate:modelValue={value => (settings.hideCursors = value)}
            modelValue={settings.hideCursors}
        />
    ))
);
Cursors.vue
<template>
    <SVGNode width="100%" height="100%" v-if="settings.hideCursors !== true">
        <!-- https://stackoverflow.com/a/31013492 -->
        <defs>
          <filter x="-10%" y="-10%" width="120%" height="120%" id="solid">
            <feFlood flood-color="var(--accent2)" result="bg" />
            <feMerge>
              <feMergeNode in="bg"/>
              <feMergeNode in="SourceGraphic"/>
            </feMerge>
          </filter>
        </defs>
        <g v-for="(cursor, id) in cursors" :key="id" class="cursor"
            :style="`transform: translate(${cursor.x}px, ${cursor.y}px)`">
            <text :style="`fill: ${cursor.color}`">🢄</text>
            <text filter="url(#solid)" y="30" v-if="cursor.message">
                {{ cursor.message }}
            </text>
        </g>
    </SVGNode>
</template>

<script setup lang="ts">
import SVGNode from "game/boards/SVGNode.vue";
import settings from "game/settings";

defineProps<{
    cursors: Record<string, {
        x: number;
        y: number;
        message?: string;
        timestamp: number;
        color: string;
    }>;
}>();
</script>
MessageInput.vue
<template>
    <div class="message-field">
        <input
            ref="input"
            v-model="value"
            placeholder="Press / to set message"
            :style="width"
            maxlength="30" />
    </div>
</template>

<script setup lang="ts">
import { computed, ref } from "vue";

const props = defineProps<{
    message: string;
}>();

const emit = defineEmits<{
    (e: "update:message", value: string): void;
}>();

defineExpose({
    focus: () => input.value?.focus()
});

const value = computed({
    get() {
        return props.message;
    },
    set(value: string) {
        emit("update:message", value);
    }
});

const input = ref<HTMLInputElement>();

const width = computed(() => `width: ${(props.message || "Press / to set message").length}ch`);
</script>

<style>
.message-field {
    background-color: var(--accent2);
    position: fixed;
    padding: .5em;
    border-radius: 1em;
    bottom: 1em;
    right: 1em;
}

.message-field input {
    font-family: monospace;
    font-size: xx-large;
    transition-duration: 0s;
    background: none;
    border: none;
    color: var(--feature-foreground);
}
</style>

Adding them to your layer should look something like this (make sure to replace the room name with your own):

export const main = createLayer("main", () => {
    const socket = createSocket<ClientSocketEvents, ServerSocketEvents>(
        "https://cursors.incremental.social/ws?room=test"
    );
    const boardRef = ref<InstanceType<typeof Board>>();
    const messageRef = ref<InstanceType<typeof MessageInput>>();
    const { cursors, message, updatePosition } = createCursors(socket, boardRef);

    const hotkey = createHotkey(() => ({
        key: "/",
        description: "Set message",
        onPress() {
            messageRef.value?.focus();
        }
    }));

    return {
        name: "Main",
        hotkey,
        display: () => (
            <>
                <Board ref={boardRef} onDrag={e => updatePosition(e)} style="height: 100%">
                    <Cursors cursors={cursors.value} />
                </Board>
                <MessageInput
                    ref={messageRef}
                    message={message.value}
                    onUpdate:message={m => (message.value = m)}
                />
            </>
        )
    };
});

i love this idea. ADD IT TO THE MAIN PROFECTUS REPOSITORY