Skip to content

Instantly share code, notes, and snippets.

@HaydenReeve
Forked from seleb/roll20 map save.js
Last active March 8, 2026 07:13
Show Gist options
  • Select an option

  • Save HaydenReeve/f752c99fb8d6a3a9b62efcc442ed3b4e to your computer and use it in GitHub Desktop.

Select an option

Save HaydenReeve/f752c99fb8d6a3a9b62efcc442ed3b4e to your computer and use it in GitHub Desktop.
script for exporting roll20 maps to an image
/** script for exporting roll20 maps to an image
how to use:
1. open your roll20 game to the page you want to save
2. open your browser's developer tools
3. copy-paste this entire file in the console
4. hit enter
5. wait for map to save and download automatically
notes:
- your UI will rapidly flash for a couple seconds when this is run, as it is quickly scrolling through the map and saving it in chunks
- it's best to run this while at 100% zoom level. it will automatically adjust if you aren't, but sometimes the first chunk doesn't save properly anyway
- this script is unfortunately not 100% reliable, as some assets may taint the canvas through CORS violations (i.e. technical reasons on roll20's end)
if this happens, you cannot save (even if you change pages) until they are removed and have refreshed. of everything tested, icons attached to images on the map were the only things that caused this issue, but i only tested a handful of free/web assets and no premium content
- very large maps may fail to save: this is dependent on your browser/hardware. if you run into this problem, try a different browser (chrome seems to be most reliable), try closing other tabs/apps to free up memory, try reducing the zoom level in the script, or try making a smaller copy of the map so you can save it in chunks
- if you have any issues using this script, feel free to reach out! https://seans.site/#contact
*/
// main
async function saveMap() {
const frameRetries = 10;
const zoom = 100; // must be one of: 10, 50, 75, 100, 150, 200, or 250
const curZoom = Number(document.querySelector('#vm_zoom_buttons .level')?.textContent || '100') || 100;
const editorWrapper = document.querySelector('#editor-wrapper');
const isJumpGate = !!window.Campaign.view.model.engine;
try {
console.log('saving map...');
// close sidebar since it interferes
if (isJumpGate && !document.querySelector('body.sidebarhidden #rightsidebar')) {
document.querySelector('#sidebarcontrol').click();
}
// get total size
const gridCellSize = 70;
const scale = zoom / 100;
const page = window.Campaign.activePage();
const width = page.get('width') * gridCellSize * scale;
const height = page.get('height') * gridCellSize * scale;
// make a canvas to output to
const outputCanvas = document.createElement('canvas');
outputCanvas.width = width;
outputCanvas.height = height;
const ctx = outputCanvas.getContext('2d', { willReadFrequently: true });
const finalCanvas = document.querySelector('#babylonCanvas');
if (!finalCanvas) throw new Error("Could not find game canvas");
// set zoom to output size
await setZoom(zoom);
// add some extra padding so we can scroll through fully
const editor = document.querySelector('#editor');
if (!editor) throw new Error("Could not find editor");
editor.style.paddingRight = `${finalCanvas.width/scale * 2}px`;
editor.style.paddingBottom = `${finalCanvas.height/scale * 2}px`;
// account for existing padding
const editorStyle = getComputedStyle(editor);
const paddingTop = isJumpGate ? 0 : parseInt(editorStyle.paddingTop, 10);
const paddingLeft = isJumpGate ? 0 : parseInt(editorStyle.paddingLeft, 10);
// scroll through and save chunks of map to output
const count = Math.ceil(width / finalCanvas.width) * Math.ceil(height / finalCanvas.height);
let progress = 0;
for (let oy = 0; oy < height; oy += finalCanvas.height) {
for (let ox = 0; ox < width; ox += finalCanvas.width) {
if (isJumpGate) {
Campaign.view.model.engine.cameraTransform.position.y =
-oy + paddingTop * scale - Campaign.view.model.engine.canvas.height/2;
Campaign.view.model.engine.cameraTransform.position.x =
ox + paddingLeft * scale + Campaign.view.model.engine.canvas.width/2;
} else {
editorWrapper.scrollTop = oy + paddingTop * scale;
editorWrapper.scrollLeft = ox + paddingLeft * scale;
}
// wait a frame for re-render
await raf();
const renderFrame = async (tries = 0) => {
// force re-render
window.Campaign.view.render();
// wait in increasingly long increments
for (let i = tries; i <= frameRetries; ++i) {
await raf();
}
const x = Math.floor(ox + finalCanvas.parentElement.offsetLeft * scale);
const y = Math.floor(oy + finalCanvas.parentElement.offsetTop * scale);
ctx.drawImage(finalCanvas, x, y);
// check top/bottom rows for transparent pixels to see if render failed
let retry = false;
const imageDataTop = ctx.getImageData(x, y, Math.min(width, finalCanvas.width), 1);
const imageDataBottom = ctx.getImageData(
x,
Math.min(height-1, y + finalCanvas.height - 1),
Math.min(width, finalCanvas.width),
1
);
for (let i = 0; i < finalCanvas.width; ++i) {
if (imageDataTop.data[i*4 + 3] === 0 || imageDataBottom.data[i*4 + 3] === 0) {
retry = true;
break;
}
}
if (retry && tries > 0) {
return renderFrame(tries-1);
} else if (retry) {
console.error(
`Could not render frame after ${frameRetries} tries; continuing anyway, but please try again with "frameRetries" set to a higher number or let me know if this keeps happening!`
);
}
};
await renderFrame(frameRetries);
console.log(`${Math.floor(++progress / count * 100)}%`);
}
}
// open output — patched to auto-download
var url = outputCanvas.toDataURL("image/png");
if (!url || url === 'data:,')
throw new Error('Could not generate data URL. This may mean the map is too large.');
// automatically trigger download
const link = document.createElement("a");
link.href = url;
link.download = `roll20-map-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
link.remove();
console.log('map saved and downloaded!');
}
finally {
// remove extra padding
editor.style.paddingRight = null;
editor.style.paddingBottom = null;
// reset zoom
await setZoom(curZoom);
}
}
// helper
function raf() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
// helper
async function setZoom(zoom) {
try {
const curZoom = parseFloat(document.querySelector('#vm_zoom_buttons .level').textContent);
if (curZoom === zoom) return;
let btns = document.querySelectorAll('.zoomDubMenuBtnStyle .el-button');
if (!btns.length) {
// open zoom popup
document.querySelector('#vm_zoom_buttons .level').click();
// give popup a couple frames to appear
await raf();
await raf();
await new Promise(r => setTimeout(r, 500));
btns = document.querySelectorAll('.zoomDubMenuBtnStyle .el-button');
}
Array.from(btns)
.map(i => [i, parseFloat(i.textContent.match(/\d+%/))])
.filter(([,i]) => !Number.isNaN(i))
.find(([,i]) => i === zoom)[0]
.click();
// give map some time to update
await raf();
await raf();
await new Promise(r => setTimeout(r, 500));
}
catch (err) {
if (zoom !== 100) {
return setZoom(100);
}
}
}
// actually run it
saveMap().catch(err => {
console.error(
`something went wrong while saving map
if the error mentions an "insecure operation", your map may be tainted (see notes at top of script for more info)
`,
err
);
});
@HaydenReeve
Copy link
Author

This version automatically downloads the map you're saving.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment