I'm not sure if this is quite what you want, but you can give it a go. It cycles between the original light color of the token, and whatever you specify for that stage. If you want the light to be completely off (and not cycling to another color) in between flashes I can add that in instead. Update - The script will pulse the light off to whatever the original state of each token in the selection was. So if it was OFF, the light will pulse off each time. If it was a different color, the 'off' state will flick back to that color. ISSUE : While a token has its low and bright light switched off, it doesn't seem to be possible to find the total light radius for that token. While the bright_light_distance is available, low_light_distance is set to 0 (this attribute would be less ambiguous as total_light_distance). The low light distance itself is stored in an attribute called light_ui (really horrid name for an attribute) which is not exposed to the sandbox. Go figure. WORKAROUND: If you want a light to pulse to total darkness (emits no bright or low light), it will default to having the total light radius set to the bright light radius, as no other radius is available from the token data. If you want low light emitting around the bright light, specify the low light radius (only low light, not combined) in the token's tooltip with the format low<radius>. Example: for a light which emits 10 feet of bright light, and a further 5 of dim, but is completely off between pulses: - set bright light distance to 10 in token settings - add the text low5 to the tooltip text (can occur anywhere in the tooltip, with or without other text) - turn the bright light & low light switches off - run the script Usage is !lightpulse [ --sel | --ids <token_ids> ] [ --stageX <color> <timings> | --break ] --ids is a comma-delimited list of token ids --sel will use the current selection of tokens instead of specifying with --ids --stageX is the stage number and settings - a color and 3 times in seconds: on, off, total. So stage1 above would be --stage1 #00ff00 2,2,20 --break can be used to stop a light animation in progress, this also needs the --id argument So for your example above, set up your token with the right radius & white or transparent light, then run: !lightpulse --id @{selected|token_id} --stage1 #00ff00 2,2,20 --stage2 #ffff00 1,1,20 --stage3 #ff0000 0.5,0.5,20 And the script: /* globals on, sendChat, getObj, */ // !lightpulse --sel --stage1 #00ff00 2,2,10 --stage2 #ff00ff 1,1,10 on('ready', () => { const scriptName = 'pulseLights'; const _activeLights = {}; const breakLight = ({ tokens }) => { tokens.forEach(token => { if (_activeLights[token.id]) _activeLights[token.id].interrupt = true }); } const cleanUpLights = (tokens) => { tokens = toArray(tokens); tokens.forEach(token => { if (_activeLights[token.id]) { setLightColor(_activeLights[token.id].color); delete _activeLights[token.id]; } }); } const checkInterrupts = (tokens) => { let lightsActive = 0; for (let i=tokens.length-1; i>=0; i--) { if (_activeLights[tokens[i].id] && _activeLights[tokens[i].id].interrupt) { // console.warn(`Interrupting light ${tokens[i].id}`); cleanUpLights(tokens[i]) tokens.splice(i, 1); } else { lightsActive ++; } } return lightsActive; } const startLightPulse = async ({ tokens, stages }) => { stages = stages.filter(v=>v); if (!tokens || !stages || !stages.length) return console.warn(`Bad data supplied to pulseLight`); tokens.forEach(token => { if (_activeLights[token.id]) cleanUpLights(token); const { color, lowLight, brightLight, lightRadius } = getTokenOriginals(token); _activeLights[token.id] = { interrupt: false, color, lowLight, brightLight, lightRadius }; }); // console.log(`Starting pulse with ${stages.length} stages...`); for (let i=0; i<stages.length; i++) { let lightsActive = 0; const stageStart = Date.now(); do { await pulseLight(tokens, stages[i]); lightsActive = checkInterrupts(tokens); } while ((stages[i].time.total > (Date.now() - stageStart)) && lightsActive); if (!lightsActive) break; } // console.log('Pulsing done'); cleanUpLights(tokens); } const pulseLight = async (tokens, { color, time }) => { // console.log('pulsing on', time, color); tokens.forEach(token => setLightColor(token, color)); await timeout(time.on); // console.log('pulsing off'); tokens.forEach(token => setLightColor(token, 'off')); await timeout(time.off); } const setLightColor = async (token, color) => { if (!_activeLights[token.id] || !color) return; const lightSettings = {}; if (color === 'off') { Object.assign(lightSettings, { lightColor: _activeLights[token.id].color, emits_low_light: _activeLights[token.id].lowLight, emits_bright_light: _activeLights[token.id].brightLight }); } else Object.assign(lightSettings, { lightColor: color, emits_low_light: true, emits_bright_light: true, low_light_distance: _activeLights[token.id].lightRadius }); // console.log(lightSettings); token.set(lightSettings); } const getTokenOriginals = (token) => { if (!token || !token.id) return; const settings = { color: token.get('lightColor'), lowLight: token.get('emits_low_light'), brightLight: token.get('emits_bright_light'), } const brightLight = token.get('bright_light_distance'), lowLightString = ((token.get('tooltip')||``).match(/low(\d+)/i)||[])[1], lowLight = token.get('low_light_distance'); settings.lightRadius = Math.max(brightLight + (parseInt(lowLightString)||0), lowLight); // console.log(settings); return settings; } const getSelected = (selection = []) => { return (selection||[]).reduce((out, sel) => { const tok = getObj('graphic', sel._id); return tok ? [ ...out, tok ] : out; }, []); } const timeout = async (ms) => new Promise(res => setTimeout(() => res(), ms)); const toChat = (msg, who) => { const whisper = who ? `/w "${who}" ` : ''; sendChat(scriptName, `${whisper}${msg}`, null, { noarchive: true }); } const toArray = (input) => Array.isArray(input) ? input : [ input ]; on('chat:message', (msg) => { if (msg.type === 'api' && /^!lightpulse\s/i.test(msg.content)) { // if (!playerIsGM(msg.playerid)) return; const commandLine = msg.content.match(/^!lightpulse\s+(.*)/)[1]; if (!commandLine) return; const commands = { tokens: null, stages: [], break: null }; commandLine.split(/\s*--\s*/g).forEach(cmd => { const parts = cmd.match(/([0-z]+)\b\s*(.*)/); // console.log(parts); if (!parts || !parts[1]) return; if (/^id/i.test(parts[1])) { if (!parts[2]) return; const ids = parts[2].split(/\s*,\s*/g); const tokens = ids.reduce((out, id) => { const tok = getObj('graphic', id); return tok ? [ ...out, tok ] : out; }, []); commands.tokens = tokens; } else if (/^sel/i.test(parts[1])) { commands.tokens = getSelected(msg.selected); } else if (/^stage\d/i.test(parts[1])) { if (!parts[2]) return; const stageColor = (parts[2].match(/#[0-9A-Fa-f]{6}/)||[])[0], stageTimerString = (parts[2].match(/[\d.]+,\s*[\d.]+,\s*[\d.]+/)||[])[0], stageTimers = stageTimerString ? stageTimerString.split(/\s*,\s*/g) : null, stageIndex = parts[1].replace(/\D/g, ''); if (stageColor && stageTimers) { commands.stages[stageIndex] = ({ color: stageColor, time: { on: stageTimers[0]*1000, off: stageTimers[1]*1000, total: stageTimers[2]*1000 } }); } } else if (/^break/i.test(parts[1])) commands.break = true; }); // console.info(commands); if (!commands.tokens.length) return toChat(`No valid tokens found.`, 'gm'); if (!commands.stages.length && !commands.break) return toChat(`No valid stage or break command`, 'gm'); if (commands.break) breakLight(commands); else startLightPulse(commands); } }); });