Skip to content

Instantly share code, notes, and snippets.

@alexloney
Last active February 22, 2026 03:12
Show Gist options
  • Select an option

  • Save alexloney/4722490cc1d8c4e7e48bf54604b6c1b5 to your computer and use it in GitHub Desktop.

Select an option

Save alexloney/4722490cc1d8c4e7e48bf54604b6c1b5 to your computer and use it in GitHub Desktop.
Simple JS code to inject into Roll20 to automatically update spell attack, save, school, and damage

Roll20 Pathfinder 2e Spell Updater

This script automates updating the MISC modifier fields for Spell Attack, Spell DC, and Damage (for feats like Dangerous Sorcery) across all standard spells on the official Roll20 Pathfinder 2e character sheet. It dynamically calculates the difference between your base stats and your target stats (from runes or temporary buffs) and injects the proper math so you don't have to manually edit every spell.

It intentionally ignores "Innate Spells" and "Focus Spells".

Configuration

At the very beginning of the script (or bookmarklet), there are two variables you can change:

  • DANGEROUS_SORCERY: Set to true to automatically add the spell's level to the damage MISC field. Set to false to reset the damage MISC field to 0.
  • MAGIC_TRADITION: Change this to 'arcane', 'divine', 'primal', 'occult', or '' (e.g. empty string) depending on your caster.

Method 1: Using the Bookmarklet (Recommended)

This is the easiest way to run the script repeatedly as you level up or change gear.

Setup

  1. Right-click anywhere on your browser's bookmarks bar and select Add Page or Add Bookmark.
  2. Set the Name to something recognizable, like Update PF2e Spells.
  3. In the URL (or Address) field, paste the minified code provided in the bookmarklet file.
  4. Click Save.

Usage

  1. Pop Out the Character Sheet: In Roll20, open your character sheet and click the Pop-Out button (the two overlapping squares icon next to your character's name) to open it in a new, dedicated window. (This is required so the script can access the sheet without getting blocked by iframes).
  2. Go to the Spells Tab: Ensure you are on the tab where all your spells are visible.
  3. Run the Bookmarklet: * If your bookmarks bar is visible in the popped-out window, simply click your Update PF2e Spells bookmark.
    • If your bookmarks bar is hidden, click the Address/URL bar at the top of the window, type Update PF2e Spells, and select the bookmark from the auto-suggest drop-down list.
  4. Wait: Do not close the sheet until the Spell updates complete! pop-up alert appears.

Method 2: Using the Developer Console

If you prefer not to save a bookmark, you can run the raw code directly.

Usage

  1. Pop Out the Character Sheet: Open your character sheet and click the Pop-Out button to open it in a new window.
  2. Go to the Spells Tab: Ensure your spells are visible.
  3. Open the Developer Console: Press F12 or Ctrl + Shift + I (Windows) / Cmd + Option + I (Mac) to open your browser's Developer Tools.
  4. Navigate to the Console Tab: Click the "Console" tab at the top of the Developer Tools window.
  5. Allow Pasting (If prompted): Some modern browsers block pasting code by default. If you see a warning, type allow pasting into the console and hit Enter.
  6. Paste and Execute: Copy the JavaScript code from pf2e_spell_updater.js, paste it into the console, and press Enter.
  7. Wait: The console will output its progress. Do not close the sheet until it says === DONE UPDATING ALL SPELLS ===.
javascript:(async()=>{const DANGEROUS_SORCERY=false;const MAGIC_TRADITION='primal';const expectedAttack=parseInt(document.querySelector('span[name="attr_spell_attack"]')?.textContent,10)||0;const expectedDC=parseInt(document.querySelector('span[name="attr_spell_dc"]')?.textContent,10)||0;if(!expectedAttack||!expectedDC){alert("Error: Could not find global fields. Ensure sheet is popped out!");return;}const tgts=Array.from(document.querySelectorAll('.repitem')).filter(s=>{const g=s.closest('.repcontainer')?.getAttribute('data-groupname')||'';if(!g.includes('spell')&&!g.includes('cantrip'))return false;if(g.includes('spellinnate')||g.includes('spellfocus'))return false;return true;});const tot=tgts.length;if(tot===0)return;const origTitle=document.title;let i=0;for(const spell of tgts){i++;document.title=`[${i}/${tot}] Updating Spells...`;const editBtn=spell.querySelector('button[name="act_settings"]');if(editBtn){editBtn.click();await new Promise(r=>setTimeout(r,300));}const updateField=(sel,val,isSel=false)=>{const el=spell.querySelector(sel);if(el){el.focus();const proto=isSel?window.HTMLSelectElement.prototype:window.HTMLInputElement.prototype;Object.getOwnPropertyDescriptor(proto,"value").set.call(el,val);el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));if(typeof $!=='undefined'){$(el).trigger('change').trigger('blur');}}};updateField('select[name*="magic_tradition"]',MAGIC_TRADITION,true);updateField('input[name*="spellattack_misc"]','0',false);updateField('input[name*="spelldc_misc"]','0',false);await new Promise(r=>setTimeout(r,600));const curAtk=parseInt(spell.querySelector('span[name="attr_spellattack"]')?.textContent,10)||0;const curDC=parseInt(spell.querySelector('span[name="attr_spelldc"]')?.textContent,10)||0;updateField('input[name*="spellattack_misc"]',(expectedAttack-curAtk).toString(),false);updateField('input[name*="spelldc_misc"]',(expectedDC-curDC).toString(),false);const lvl=spell.querySelector('input[name*="spelllevel"]')?.value||"0";if(DANGEROUS_SORCERY){updateField('input[name*="damage_misc"]',lvl,false);}else{updateField('input[name*="damage_misc"]','0',false);}await new Promise(r=>setTimeout(r,500));if(editBtn){editBtn.click();await new Promise(r=>setTimeout(r,150));}}document.title=origTitle;alert("Spell updates complete!");})();
(async () => {
console.log("=== STARTING PF2E SPELL UPDATER ===");
// --- CONFIGURATION ---
// Set to true if you have Dangerous Sorcery (adds spell level to damage).
// Set to false to explicitly set the Damage MISC field to 0.
const DANGEROUS_SORCERY = false;
// Set your spellcasting tradition here.
// OPTIONS: 'arcane', 'divine', 'primal', 'occult', or 'empty-string'
const MAGIC_TRADITION = 'primal';
// --- GLOBAL TARGETS ---
// Fetch the target global spell attack and spell DC from the top of the character sheet.
const expectedAttack = parseInt(document.querySelector('span[name="attr_spell_attack"]')?.textContent, 10) || 0;
const expectedDC = parseInt(document.querySelector('span[name="attr_spell_dc"]')?.textContent, 10) || 0;
console.log(`[GLOBAL] Found Expected Attack: ${expectedAttack} | Expected DC: ${expectedDC}`);
// Abort if the script can't find the global stats (usually means the sheet isn't popped out).
if (!expectedAttack || !expectedDC) {
console.error("[ERROR] Could not find the global fields. Ensure the sheet is popped out into its own window!");
return;
}
// --- GATHER & FILTER SPELLS ---
// Grab every repeating item on the sheet, then filter down to just the ones we want to edit.
const allRepItems = document.querySelectorAll('.repitem');
const targetSpells = Array.from(allRepItems).filter(spell => {
const groupName = spell.closest('.repcontainer')?.getAttribute('data-groupname') || '';
// Only keep standard spells and cantrips
if (!groupName.includes('spell') && !groupName.includes('cantrip')) return false;
// Explicitly ignore Innate Spells and Focus Spells
if (groupName.includes('spellinnate') || groupName.includes('spellfocus')) return false;
return true;
});
const totalSpells = targetSpells.length;
console.log(`[SCAN] Found ${totalSpells} target spells to update.`);
if (totalSpells === 0) return;
// --- SAVE ORIGINAL WINDOW TITLE ---
// We save this so we can restore it after using the title bar as a progress indicator.
const originalTitle = document.title;
// --- PROCESS SPELLS ---
let currentIndex = 0;
for (const spell of targetSpells) {
currentIndex++;
// Update the browser tab title with progress (e.g., "[1/20] Updating Spells...")
document.title = `[${currentIndex}/${totalSpells}] Updating Spells...`;
const nameEl = spell.querySelector('input[name*="name"]');
const spellName = nameEl ? nameEl.value : "Unnamed Spell";
console.log(`\n--- Processing [${currentIndex}/${totalSpells}]: ${spellName} ---`);
// --- OPEN SPELL BLOCK ---
// Click the settings button to reveal the backend HTML fields so they can be edited.
const editBtn = spell.querySelector('button[name="act_settings"]');
if (editBtn) {
editBtn.click();
// Wait for Roll20 to animate and render the internal fields
await new Promise(resolve => setTimeout(resolve, 300));
}
// --- UPDATE HELPER FUNCTION ---
// Bypasses framework state managers to inject a value, then forces DOM events so Roll20 saves it.
const updateField = (selector, val, fieldName, isSelect = false) => {
const el = spell.querySelector(selector);
if (el) {
el.focus(); // Emulate a user clicking into the field
// Call the native value setter directly
const proto = isSelect ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, "value").set.call(el, val);
// Dispatch standard browser events
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('blur', { bubbles: true })); // Blur is critical for Roll20 saves
// Force jQuery events for Roll20 sheet workers
if (typeof $ !== 'undefined') {
$(el).trigger('change').trigger('blur');
}
console.log(`[WRITE] Updated ${fieldName} to: ${val}`);
} else {
console.warn(`[WARN] Could not find field for ${fieldName}`);
}
};
// --- PHASE 1: CLEAN SLATE ---
// Set tradition and zero out MISC fields so Roll20 can calculate the pure base stats
console.log(`[ACTION] Setting tradition and forcing Roll20 to recalculate base stats...`);
updateField('select[name*="magic_tradition"]', MAGIC_TRADITION, 'Tradition', true);
updateField('input[name*="spellattack_misc"]', '0', 'Temp Attack MISC', false);
updateField('input[name*="spelldc_misc"]', '0', 'Temp Save DC MISC', false);
// Wait for Roll20's sheet workers to process the tradition and update the DOM
await new Promise(resolve => setTimeout(resolve, 600));
// --- PHASE 2: CALCULATE TRUE DIFFERENCE ---
// Now that the base stats are accurate, calculate the exact gap to reach the global target.
const currentAttackTotal = parseInt(spell.querySelector('span[name="attr_spellattack"]')?.textContent, 10) || 0;
const currentDCTotal = parseInt(spell.querySelector('span[name="attr_spelldc"]')?.textContent, 10) || 0;
const newAttackMisc = expectedAttack - currentAttackTotal;
const newDCMisc = expectedDC - currentDCTotal;
console.log(`[MATH] Attack gap: Expected (${expectedAttack}) - Current Base (${currentAttackTotal}) = ${newAttackMisc}`);
console.log(`[MATH] Save DC gap: Expected (${expectedDC}) - Current Base (${currentDCTotal}) = ${newDCMisc}`);
// --- PHASE 3: APPLY FINAL VALUES ---
// Inject the calculated MISC values
updateField('input[name*="spellattack_misc"]', newAttackMisc.toString(), 'Final Attack MISC', false);
updateField('input[name*="spelldc_misc"]', newDCMisc.toString(), 'Final Save DC MISC', false);
// Grab the base level of the spell to use for Dangerous Sorcery
const levelInput = spell.querySelector('input[name*="spelllevel"]');
const spellLevel = levelInput ? levelInput.value : "0";
// Conditionally apply Damage MISC based on the configuration variable
if (DANGEROUS_SORCERY) {
updateField('input[name*="damage_misc"]', spellLevel, 'Damage MISC', false);
} else {
updateField('input[name*="damage_misc"]', '0', 'Damage MISC', false);
}
// --- CLOSE AND SAVE ---
console.log(`[WAIT] Saving data to Roll20 backend...`);
// Pause to let the database catch up before removing the element from the DOM
await new Promise(resolve => setTimeout(resolve, 500));
// Click the gear icon again to collapse the spell block
if (editBtn) {
editBtn.click();
await new Promise(resolve => setTimeout(resolve, 150));
}
}
// --- RESTORE ORIGINAL TITLE ---
// Put the browser tab title back to normal once we're done
document.title = originalTitle;
console.log("\n=== DONE UPDATING ALL SPELLS ===");
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment