A basic teleport tool that lets GMS and players click a token to jump to a page/map, split off from the party , and rejoin later. Note: Roll20 does not allow any script to move the GM’s own view. How it works Name your destination page. Example: World Map . Place/select a token whose Token Name matches (exact or partial) the destination page name. Run !goto with the token selected. Menu appears: GM: Move ALL players (ribbon) to the page; Rejoin everyone. Player: Move me (split just you); Rejoin party . Commands (quick reference) !goto — Menu from selected token’s Name → page match (exact, then fuzzy). !goto --all --page <pageId> — GM: Move all players (ribbon) to <pageId> (does not move GM view). !goto --me --page <pageId> — Player: Split only yourself to <pageId> . !goto --rejoin — GM: Clear all splits; Player: Clear your split. !goto --pages — List pages (name/id). !goto --debug — Show ribbon id, your split id, storage style. Notes & Tips Players must have permission on the token or be covered by “All Players” . Set the token (or its represented character) Controlled By to the player or All Players . GM view won’t move: click the page (or press P with Advanced Shortcuts enabled). Archived pages are ignored. Uses ( playerspecificpages ) so players can split and rejoin independently. /* Goto Version: 1.7.3 Author: Surok & GPT-5 Thinking Commands: !goto -> menu from selected token's Name (page lookup: exact, then fuzzy) !goto --all --page <pageId> -> GM: move ALL players (ribbon) to pageId (does NOT move GM) !goto --me --page <pageId> -> Player: split ONLY you to pageId (Jumpgate pulse) !goto --rejoin -> GM: clear all splits; Player: clear your split !goto --debug -> Show ribbon id, your split id, storage style !goto --pages -> List pages (name [archived?] : id) Notes: - No GM auto-move. - Jumpgate pulse = write playerspecificpages → false → write again. */ on('ready', () => { const CMD_GOTO = '!goto'; // ---------- utils ---------- const safe = (s) => String(s||'') .replace(/&/g,'&').replace(/</g,'<') .replace(/>/g,'>').replace(/"/g,'"') .replace(/'/g,'''); const whoObj = (pid) => getObj('player', pid); const whoName = (pid) => (whoObj(pid)?.get('displayname')) || 'Player'; const whisperGM = (line) => sendChat('Goto', `/w gm ${line}`); const whisperTo = (pid,line) => sendChat('Goto', `/w "${whoName(pid)}" ${line}`); const btn = (label, command) => `<a href="${safe(command)}" style="display:inline-block;margin:2px;padding:3px 7px;border:1px solid #888;border-radius:4px;text-decoration:none;">${safe(label)}</a>`; const box = (inner) => `<div style="border:1px solid #999;border-radius:6px;padding:6px;margin:4px 0;background:#f8f8f8;">${inner}</div>`; const getSelGraphics = (msg) => (msg.selected || []) .filter(s => s && s._type === 'graphic') .map(s => getObj('graphic', s._id)) .filter(Boolean); const tokenName = (g) => (g.get('name') || '').trim(); const findPageByName = (name) => { if (!name) return null; const pages = findObjs({type:'page'}) || []; const needle = name.trim().toLowerCase(); const exact = pages.find(p => (p.get('name')||'').trim().toLowerCase() === needle); if (exact) return exact; return pages.find(p => (p.get('name')||'').toLowerCase().includes(needle)) || null; }; const listPages = () => { const pages = findObjs({type:'page'}) || []; if (!pages.length) return '(no pages)'; return pages.map(p => { const a = p.get('archived') ? ' [archived]' : ''; return `${safe(p.get('name')||'(unnamed)')}${a} : <code>${p.id}</code>`; }).join('<br>'); }; const parseFlags = (content) => { const parts = content.trim().split(/\s+--/).map((p,i)=> i===0 ? p : `--${p}`); parts.shift(); const flags = {}; parts.forEach(seg => { const m = seg.match(/^--?([a-z][\w-]*)(?:\s+(.+))?$/i); if (!m) return; const k = m[1].toLowerCase(); const v = (m[2]||'').trim(); flags[k] = v || true; }); return flags; }; // Prefer the token's sole controller (character.controlledby with one id); else use the clicker const choosePlayerId = (msg) => { const sel = getSelGraphics(msg); if (sel.length) { const g = sel[0]; let controllers = ''; if (g.get('represents')) { const ch = getObj('character', g.get('represents')); controllers = ch ? ch.get('controlledby') : ''; } else { controllers = g.get('controlledby'); } if (controllers && controllers !== 'all') { const arr = controllers.split(',').filter(Boolean); if (arr.length === 1) return arr[0]; } } return msg.playerid; }; // ---------- playerspecificpages (Jumpgate pulse) ---------- const readPSP = () => { const raw = Campaign().get('playerspecificpages'); if (!raw) return { map:{}, style:'empty' }; if (typeof raw === 'string') { try { return { map: JSON.parse(raw) || {}, style:'json' }; } catch { return { map:{}, style:'json' }; } } if (typeof raw === 'object') return { map: raw || {}, style:'object' }; return { map:{}, style:'unknown' }; }; const writePSPObject = (map) => Campaign().set('playerspecificpages', map); const pulsePSP = (map) => { writePSPObject(map); setTimeout(() => { Campaign().set('playerspecificpages', false); setTimeout(() => writePSPObject(map), 120); }, 120); }; const clearAllSplits = () => Campaign().set('playerspecificpages', false); const moveRibbonTo = (pageId) => Campaign().set('playerpageid', pageId); const pingCenter = (pageObj, playerid) => { try { const pid = pageObj.id; const cx = ((pageObj.get('width')||70) * 35)/2; const cy = ((pageObj.get('height')||70) * 35)/2; setTimeout(() => sendPing(cx, cy, pid, playerid, false), 250); } catch (e) {} }; // Split one player with Jumpgate pulse + ping const splitOne = (playerid, pageObj) => { if (playerIsGM(playerid)) { whisperTo(playerid, `You’re a GM—splits don’t move GMs. Use “Rejoin as Player” or test with a player account.`); return; } const { map } = readPSP(); map[playerid] = pageObj.id; pulsePSP(map); pingCenter(pageObj, playerid); }; // ---------- menu ---------- const showMenu = (msg) => { const isGM = playerIsGM(msg.playerid); const sel = getSelGraphics(msg); if (!sel.length) { whisperTo(msg.playerid, box(`Select a token whose <b>Name</b> matches a destination page, then use <code>${safe(CMD_GOTO)}</code>.`)); return; } const t = sel[0]; const name = tokenName(t); if (!name) { whisperTo(msg.playerid, box(`Selected token has no <b>Name</b>.`)); return; } const page = findPageByName(name); if (!page) { whisperTo(msg.playerid, box(`No page found matching <b>${safe(name)}</b>.`)); return; } if (page.get('archived')) { whisperTo(msg.playerid, box(`Page <b>${safe(page.get('name'))}</b> is archived.`)); return; } const pName = page.get('name') || name; const pid = page.id; if (isGM) { const bAll = btn(`Move ALL players to “${pName}”`, `${CMD_GOTO} --all --page ${pid}`); const bRejoin = btn(`Rejoin players to main ribbon`, `${CMD_GOTO} --rejoin`); whisperGM(box(`<b>Destination:</b> ${safe(pName)}<br>${bAll} ${bRejoin}`)); } else { const bMe = btn(`Move me to “${pName}”`, `${CMD_GOTO} --me --page ${pid}`); const bRejoin = btn(`Rejoin party`, `${CMD_GOTO} --rejoin`); whisperTo(msg.playerid, box(`<b>Destination:</b> ${safe(pName)}<br>${bMe} ${bRejoin}`)); } }; // ---------- executor ---------- const doGoto = (msg) => { const flags = parseFlags(msg.content); const isGM = playerIsGM(msg.playerid); // Helpers if (flags.pages) { const body = listPages(); if (isGM) whisperGM(box(body)); else whisperTo(msg.playerid, box(body)); return; } if (flags.debug) { const curRibbon = Campaign().get('playerpageid'); const { map, style } = readPSP(); const mySplit = map[msg.playerid]; const lines = [ `<b>Ribbon Page:</b> ${safe(curRibbon || '(none)')}`, `<b>Your Split:</b> ${safe(mySplit || '(none)')}`, `<b>Split Map Size:</b> ${Object.keys(map).length}`, `<b>Storage Style:</b> ${safe(style)}` ]; if (isGM) whisperGM(box(lines.join('<br>'))); else whisperTo(msg.playerid, box(lines.join('<br>'))); return; } // Rejoin if (flags.rejoin) { if (isGM) { clearAllSplits(); whisperGM(`All players rejoined the main ribbon.`); } else { const { map } = readPSP(); delete map[msg.playerid]; pulsePSP(map); whisperTo(msg.playerid, `You rejoined the main ribbon.`); } return; } // GM move all (ribbon) if (flags.all) { if (!isGM) { whisperTo(msg.playerid, `Only a GM can move all players.`); return; } const pageId = (flags.page && String(flags.page)) || ''; const page = pageId ? getObj('page', pageId) : null; if (!page) { whisperGM(`Missing or invalid <code>--page</code>. Use the menu button again.`); return; } if (page.get('archived')) { whisperGM(`Page <b>${safe(page.get('name'))}</b> is archived.`); return; } clearAllSplits(); moveRibbonTo(pageId); whisperGM(`Moved <b>all players</b> to: <b>${safe(page.get('name'))}</b>.`); return; } // Player move self if (flags.me) { const pageId = (flags.page && String(flags.page)) || ''; const page = pageId ? getObj('page', pageId) : null; if (!page) { whisperTo(msg.playerid, `Missing or invalid <code>--page</code>. Use the menu button again.`); return; } if (page.get('archived')) { whisperTo(msg.playerid, `Page <b>${safe(page.get('name'))}</b> is archived.`); return; } const targetPlayerId = choosePlayerId(msg); splitOne(targetPlayerId, page); whisperTo(msg.playerid, `Sending you to: <b>${safe(page.get('name'))}</b>…`); return; } // No flags => show the menu showMenu(msg); }; // ---------- router ---------- on('chat:message', (msg) => { if (msg.type !== 'api' || !msg.content) return; if (msg.content.startsWith(CMD_GOTO)) { doGoto(msg); } }); });