
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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;')
.replace(/'/g,'&#39;');
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); }
});
});