
Hey everyone,
I wanted to share with you a script that creates combat ready NPCs in Roll20. This code listens for chat message events that start with "!import-npc", which is a command to create an NPC. The code then extracts and parses the JSON data from the chat message to create a new character object for the NPC. The full script will be posted at the end.
What the script is doing:
The code has a function that replaces placeholder strings with proper roll syntax, and another function to concatenate ability descriptions for the NPC. The code creates abilities (traits, actions, reactions, legendary actions, and lair actions) for the NPC and sets some of them as token actions. The code creates various attributes for the NPC, such as size, race, alignment, HP, and speed, as well as combat-related attributes like AC, saving throws, and skills. The code also sends a chat message to confirm the creation of the NPC.
Finally, the code handles two additional commands: "!token-default-init" and "!export-stat-block". Both of these can be run through a token macro automatically added to the NPC. The macro is then removed from the token macro bar after it is created.
"Export Stat Block to Handout" macro creates a referenceable stat block, formatted with HTML, as a handout within roll20 for each custom enemy. (Helpful to reference during combat).
"Initialize Token Defaults" will pull default values for HP, AC, and CR in the 3 token bars and set the visibilities. Don't forget to click "update default token" after using this macro. If you forget, you can run the ability through the abilities section again.
How to use this script:
Install the script to your roll20.net campaign.
Create a text string to import your custom enemy. It needs to be a single string text entry and there is a necessary syntax for getting the rolls to work properly for any action macros you'd like it to create. "@RFR@" will replace with "[[" and "@RBK@" will replace with "]]". We needed to use placeholder strings as the chat would interpret [[1d6]] as if we wanted to roll it then, and ends up hardcoding the result of that one roll into our macro. So keeping this in mind, the proper syntax for a usable macro action that rolls to the GM only would be "/w gm Jeff swings with his longsword @RFR@1d20+3@RBK@ and deals @RFR@1d8+3@RBK@ slashing damage!" Here is an example of a full import string. Feel free to use this one as a test to get an understanding!
!import-npc {"name": "Vicious Fire Elemental", "size": "Large", "type": "Elemental", "alignment": "Neutral Evil", "languages": "Ignan", "STR": 14, "DEX": 18, "CON": 16, "INT": 8, "WIS": 10, "CHA": 9, "combat": true, "AC": 15, "HP": 110, "HP_formula": "13d10+39", "speed": "50 ft", "saving_throws": "DEX +7, CON +6", "skills": "Acrobatics +7, Intimidation +2", "senses": "Darkvision 60 ft, Passive Perception 12", "challenge": "6", "description": "This towering, humanoid figure appears to be composed entirely of searing flames. Its eyes flicker with menace, and it moves with a swift, unpredictable grace.", "traits": [{"name": "Elemental Demise", "description": "When the Vicious Fire Elemental dies, it explodes in a burst of flame. Each creature within 10 feet of it must make a DC 15 Dexterity saving throw, taking @RFR@3d6@RBK@ fire damage on a failed save, or half as much damage on a successful one."}, {"name": "Fire Form", "description": "The Vicious Fire Elemental can move through a space as narrow as 1 inch wide without squeezing. A creature that touches the elemental or hits it with a melee attack while within 5 feet of it takes @RFR@1d10@RBK@ fire damage. In addition, the elemental can enter a hostile creature's space and stop there. The first time it enters a creature's space on a turn, that creature takes @RFR@1d10@RBK@ fire damage and catches fire."}], "actions": [{"name": "Touch", "description": "/w gm reaches out with a fiery touch! @RFR@1d20 + 7@RBK@ to hit, reach 5 ft., one target. Hit: @RFR@2d6 + 4@RBK@ fire damage."}, {"name": "Fire Blast", "description": "/w gm expels a blast of fire! @RFR@1d20 + 7@RBK@ to hit, range 60 ft., one target. Hit: @RFR@3d6 + 4@RBK@ fire damage, and the target is pushed 10 feet away."}], "reactions": [{"name": "Molten Reprisal", "description": "/w gm When a creature within 5 feet of the Vicious Fire Elemental hits it with a melee attack, the elemental can use its reaction to deal @RFR@1d10@RBK@ fire damage to the attacker."}], "legendary_actions": [{"name": "Fire Burst", "description": "/w gm The Vicious Fire Elemental causes a small explosion at a point it can see within 60 feet of it. Each creature within 5 feet of that point must make a DC 15 Dexterity saving throw, taking @RFR@2d6@RBK@ fire damage on a failed save, or half as much damage on a successful one."}], "lair_actions": [{"name": "Flame Vortex", "description": "/w gm A vortex of flame appears in a 10-foot-radius, 20-foot-high cylinder centered on a point the Vicious Fire Elemental can see within 60 feet of it. Each creature in that area must make a DC 15 Dexterity saving throw, taking @RFR@4d6@RBK@ fire damage on a failed save, or half as much damage on a successful one. The vortex lasts until the start of the Vicious Fire Elemental's next lair action."}], "resistances": "bludgeoning, piercing, and slashing from nonmagical attacks", "immunities": "fire, poison", "vulnerabilities": "cold", "condition_immunities": "exhaustion, grappled, paralyzed, petrified, poisoned, prone, restrained, unconscious", "spellcasting_ability": "Wisdom", "spell_save_dc": 13, "spell_attack_bonus": 5, "spell_slots": "1st level: 4, 2nd level: 3, 3rd level: 2", "npc_ac_note": "natural armor", "npc_type": "fire"}
Okay. Next step! We should see a confirmation in the text that our NPC was created. Navigate and open up the NPC that was created. When prompted click "NPC" as the desired sheet type. You will notice that some things populate correctly in the sheet, but others are empty. Not to worry, we've got a workaround.
Next: Drag your NPC onto the battlefield to create a token. This is where we will set up our default token. When the token is selected, you should see a variety of different macros available. Click "Export Stat Block to Handout." This macro creates a referenceable stat block, formatted with HTML, as a handout within roll20 for each custom enemy. (Helpful to reference during combat). Next, click "Initialize Token Defaults." This will pull default values for HP, AC, and CR in the 3 token bars and set the visibilities. Click "update default token" to save this info to the default token after using this macro. If you forget, you can run the ability through the abilities section again.
You're done! You have now imported a custom enemy within roll20.net for the OGL 5e character sheet, complete with usable macros and a stat block to reference during combat.
Pro Plays Section:
Utilizing CHATGPT to create or format stat blocks:
- Teach chatGPT the syntax of our single string JSON text command. ChatGPT-4 does a much better job of this than 3 or 3.5. For this, I copy a completed and functioning import string, such as the one I provided you with the following prompt. "Today you will help me convert DND 5e stat blocks into the single string JSON format. This will be used for an import API for roll20.net to create a custom NPC. Please ensure actions have the correct syntax for rolls as I've demonstrated in the example with /w GM beginning each ability and @RFR@ and @RBK@ to represent the rolls for those abilities. Here is an example of a full stat block with the correct formatting:" Then copy and paste your example, then click enter.
- Now, paste in stat block information you create independently, copy from a website, or create collaboratively with chatGPT in another ongoing chat.
Note: You may need to do a little experimentation, and double-checking. I do not recommend creating stat blocks in the same chat as for formatting to JSON, as chatGPT will tend to weight the stat blocks based upon what you provided. Ie. if you provide an example stat block with a lair action, it wants to give the enemies you create lair actions a little too often.
Here is the full script:
on('ready', function () { // This function replaces placeholder strings with proper roll syntax. function replaceRollPlaceholders(str) { return str.replace(/@RFR@/g, '[[').replace(/@RBK@/g, ']]'); } on('chat:message', function (msg) { // Listen for chat message events that start with "!import-npc" if (msg.type === 'api' && msg.content.startsWith('!import-npc')) { // Extract and parse the JSON data from the chat message let npcJson = msg.content.slice('!import-npc'.length).trim(); let npcData = JSON.parse(npcJson); // Create a new character object for the NPC let character = createObj('character', { name: npcData.name, npc: true }); // Function to create attributes for the NPC const createAttribute = (name, value) => { createObj('attribute', { characterid: character.id, name: name, current: value }); }; const fullStatBlock = ` <style> .npc-stat-block { font-family: sans-serif; font-size: 14px; line-height: 1.4; } .npc-stat-block h1 { font-size: 20px; margin-bottom: 5px; } .npc-stat-block h2 { font-size: 16px; margin-top: 10px; margin-bottom: 5px; } .npc-stat-block p { margin: 0; } .npc-stat-block ul { margin: 0; padding-left: 20px; } .npc-stat-block table { border-collapse: collapse; width: 100%; } .npc-stat-block th, .npc-stat-block td { text-align: center; font-weight: bold; } </style> <div class="npc-stat-block"> <h1>${npcData.name || ''}</h1> <hr> <p><strong>Size:</strong> ${npcData.size || ''}</p> <p><strong>Type:</strong> ${npcData.type || ''}</p> <p><strong>Alignment:</strong> ${npcData.alignment || ''}</p> <p><strong>AC:</strong> ${npcData.AC || ''} ${npcData.ac_note ? `(${npcData.ac_note})` : ''}</p> <p><strong>HP:</strong> ${npcData.HP || ''} (${npcData.HP_formula || ''})</p> <p><strong>Speed:</strong> ${npcData.speed || ''}</p> <hr> <table> <tr> <th>STR</th> <th>DEX</th> <th>CON</th> <th>INT</th> <th>WIS</th> <th>CHA</th> </tr> <tr> <td>${npcData.STR || 0} (${Math.floor((npcData.STR - 10) / 2)})</td> <td>${npcData.DEX || 0} (${Math.floor((npcData.DEX - 10) / 2)})</td> <td>${npcData.CON || 0} (${Math.floor((npcData.CON - 10) / 2)})</td> <td>${npcData.INT || 0} (${Math.floor((npcData.INT - 10) / 2)})</td> <td>${npcData.WIS || 0} (${Math.floor((npcData.WIS - 10) / 2)})</td> <td>${npcData.CHA || 0} (${Math.floor((npcData.CHA - 10) / 2)})</td> </tr> </table> <hr> <p><strong>Saving Throws:</strong> ${npcData.saving_throws || ''}</p> <p><strong>Skills:</strong> ${npcData.skills || ''}</p> <p><strong>Damage Resistances:</strong> ${npcData.resistances || 'None'}</p> <p><strong>Damage Immunities:</strong> ${npcData.immunities || 'None'}</p> <p><strong>Damage Vulnerabilities:</strong> ${npcData.vulnerabilities || 'None'}</p> <p><strong>Condition Immunities:</strong> <p><strong>Senses:</strong> ${npcData.senses || ''}</p> <p><strong>Languages:</strong> ${npcData.languages || ''}</p> <p><strong>Challenge:</strong> ${npcData.challenge || ''}</p> <h2>Description</h2> <p>${npcData.description || ''}</p> ${npcData.traits ? '<h2>Traits</h2><ul>' + concatenateAbilityDescriptions('traits', true) + '</ul>' : ''} ${npcData.actions ? '<h2>Actions</h2><ul>' + concatenateAbilityDescriptions('actions', true) + '</ul>' : ''} ${npcData.reactions ? '<h2>Reactions</h2><ul>' + concatenateAbilityDescriptions('reactions', true) + '</ul>' : ''} ${npcData.legendary_actions ? '<h2>Legendary Actions</h2><ul>' + concatenateAbilityDescriptions('legendary_actions', true) + '</ul>' : ''} ${npcData.lair_actions ? '<h2>Lair Actions</h2><ul>' + concatenateAbilityDescriptions('lair_actions', true) + '</ul>' : ''} </div> `; // ... Previous code ... createObj('ability', { characterid: character.id, name: 'Export Stat Block to Handout', action: `!export-stat-block @{${character.get('name')}|character_id}`, istokenaction: true }); // Function to concatenate ability descriptions function concatenateAbilityDescriptions(type, asList = false) { if (!npcData[type]) { return ''; } return npcData[type].map(ability => asList ? `<li><strong>${ability.name}:</strong> ${replaceRollPlaceholders(ability.description)}</li>` : `${ability.name}: ${replaceRollPlaceholders(ability.description)}`).join('\n'); } // Set basic NPC attributes createAttribute('size', npcData.size || ''); createAttribute('race', npcData.type || ''); createAttribute('alignment', npcData.alignment || ''); createAttribute('npc_languages', npcData.languages || ''); createAttribute('npc_name', npcData.name || ''); createAttribute('description', npcData.description || ''); // Set combat-related attributes if the NPC has combat data if (npcData.combat) { // Store the full stat block in an attribute createAttribute('full_stat_block', fullStatBlock); createAttribute('npc_ac', npcData.AC || ''); createAttribute('wtype', 'always_whisper'); createAttribute('npc_hpbase', npcData.HP || ''); createAttribute('npc_hpformula', npcData.HP_formula || ''); createAttribute('npc_speed', npcData.speed || ''); createAttribute('strength_base', npcData.STR || 0); createAttribute('dexterity_base', npcData.DEX || 0); createAttribute('constitution_base', npcData.CON || 0); createAttribute('intelligence_base', npcData.INT || 0); createAttribute('wisdom_base', npcData.WIS || 0); createAttribute('charisma_base', npcData.CHA || 0); // Set additional attributes as specified createAttribute('traits_text', concatenateAbilityDescriptions('traits')); createAttribute('actions_text', concatenateAbilityDescriptions('actions') + concatenateAbilityDescriptions('reactions') + concatenateAbilityDescriptions('legendary_actions') + concatenateAbilityDescriptions('lair_actions')); createAttribute('npc_saving_throws', npcData.saving_throws || ''); createAttribute('npc_skills', npcData.skills || ''); createAttribute('npc_senses', npcData.senses || ''); createAttribute('challenge', npcData.challenge || ''); createAttribute('npc_traits', npcData.traits_text || ''); createAttribute('npc_resistances', npcData.resistances || ''); createAttribute('npc_immunities', npcData.immunities || ''); createAttribute('npc_vulnerabilities', npcData.vulnerabilities || ''); createAttribute('npc_condition_immunities', npcData.condition_immunities || ''); createAttribute('spellcasting_ability', npcData.spellcasting_ability || ''); createAttribute('spell_save_dc', npcData.spell_save_dc || ''); createAttribute('spell_attack_bonus', npcData.spell_attack_bonus || ''); createAttribute('spell_slots', npcData.spell_slots || ''); createAttribute('npc_ac_note', npcData.ac_note || ''); createAttribute('npc_type', npcData.npc_type || ''); // Function to add abilities (traits, actions, reactions, etc.) for the NPC const addAbilities = (type, tokenAction) => { if (npcData[type]) { for (const ability of npcData[type]) { createObj('ability', { characterid: character.id, name: ability.name, action: replaceRollPlaceholders(ability.description), istokenaction: tokenAction }); } } }; // Add different types of abilities and set token actions as needed addAbilities('traits', false); addAbilities('actions', true); // Set actions as token actions addAbilities('reactions', true); // Set reactions as token actions addAbilities('legendary_actions', true); // Set legendary actions as token actions addAbilities('lair_actions', true); // Set lair actions as token actions } createObj('ability', { characterid: character.id, name: 'Token Default Initialization', action: `!token-default-init @{${character.get('name')}|character_id}`, istokenaction: true, showplayers: false }); // Send a chat message confirming the creation of the NPC sendChat('API', `Created NPC: ${npcData.name}`); } on('chat:message', function (msg) { // ... Previous code ... if (msg.type === 'api' && msg.content.startsWith('!token-default-init')) { let characterId = msg.content.slice('!token-default-init'.length).trim(); let character = getObj('character', characterId); if (character) { let selectedToken = msg.selected && msg.selected[0] && getObj('graphic', msg.selected[0]._id); if (selectedToken && selectedToken.get('represents') === characterId) { selectedToken.set({ "bar1_value": findObjs({type: 'attribute', characterid: character.id, name: 'challenge'})[0].get("current"), "bar1_max": "", "bar2_value": findObjs({type: 'attribute', characterid: character.id, name: 'npc_ac'})[0].get("current"), "bar3_value": findObjs({type: 'attribute', characterid: character.id, name: 'npc_hpbase'})[0].get("current"), "bar3_max": findObjs({type: 'attribute', characterid: character.id, name: 'npc_hpbase'})[0].get("current"), "showname": true, "showplayers_name": false, "showplayers_bar1": false, "showplayers_bar2": false, "showplayers_bar3": false }); let tokenInitAbility = findObjs({ type: 'ability', characterid: character.id, name: 'Token Default Initialization' })[0]; if (tokenInitAbility) { tokenInitAbility.set('istokenaction', false); } } else { sendChat('API', 'Error: Please select a token representing the character before executing the command.'); } } else { sendChat('API', 'Error: Character not found.'); } } // Handle "!export-stat-block" command if (msg.type === 'api' && msg.content.startsWith('!export-stat-block')) { let characterId = msg.content.slice('!export-stat-block'.length).trim(); let character = getObj('character', characterId); if (character) { let statBlock = findObjs({ characterid: character.id, name: 'full_stat_block' })[0]; if (statBlock) { let handout = findObjs({ type: 'handout', name: character.get('name') + ' - Stat Block' })[0]; if (!handout) { handout = createObj('handout', { name: character.get('name') + ' - Stat Block' }); } handout.set('notes', statBlock.get('current')); sendChat('API', `Exported ${character.get('name')} stat block to handout.`); // Update the 'istokenaction' property for 'Export Stat Block to Handout' let exportStatBlockAbility = findObjs({ type: 'ability', characterid: character.id, name: 'Export Stat Block to Handout' })[0]; if (exportStatBlockAbility) { exportStatBlockAbility.set('istokenaction', false); } } else { sendChat('API', 'Error: Stat block not found.'); } } else { sendChat('API', 'Error: Character not found.'); } } }); }); });