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:
[details="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/3166329function 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, boardRef: Ref | 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?.getBoundingClientRect() ?? { x: 0, y: 0, width: 0, height: 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; position.x /= stageRect.width / 100; position.y /= stageRect.height / 100; 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(() => ( Hide other users' cursors} onUpdate:modelValue={value => (settings.hideCursors = value)} modelValue={settings.hideCursors} /> )));[/details]
[details="Cursors.vue"]
[/details]
[details="MessageInput.vue"]
[/details]
You may also need to take some of the changes I made to the Board and SVGNode components to get positions working properly.
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( "https://cursors.incremental.social/ws?room=test" ); const boardRef = ref>(); const messageRef = ref>(); const { cursors, message, updatePosition } = createCursors(socket, boardRef); const hotkey = createHotkey(() => ({ key: "/", description: "Set message", onPress() { messageRef.value?.focus(); } })); return { name: "Main", hotkey, display: () => ( <> updatePosition(e)} style="height: calc(100vh - 50px); margin-top: -50px; margin-bottom: -50px; margin-left: -10px; margin-right: -10px;" > (message.value = m)} /> ) };});Discuss this on our forum.
i love this idea. ADD IT TO THE MAIN PROFECTUS REPOSITORY