I wanted to be able to have some animated fans or blade traps in a dungeon I was working on, so I took The Aaron's Bounce Token script and adapted it to spinning. Here i is in action: Here is the script. There plenty of variables you can adjust in the script to affect the speed / movement intervals. // By: Kastion (Adaptation of The Aaron, Arcane Scriptomancer's BounceToken Script) // Profile: <a href="https://app.roll20.net/users/3173313/kastion" rel="nofollow">https://app.roll20.net/users/3173313/kastion</a> var SpinTokens = SpinTokens || (function(){ 'use strict'; var version = '0.1.2', lastUpdate = 1523205020, schemaVersion = 0.1, stepRate = 25, defaultSecondsPerCycle = 1, millisecondsPerSecond = 1000, ch = function (c) { var entities = { '<' : 'lt', '>' : 'gt', "'" : '#39', '@' : '#64', '{' : '#123', '|' : '#124', '}' : '#125', '[' : '#91', ']' : '#93', '"' : 'quot', '-' : 'mdash', ' ' : 'nbsp' }; if(_.has(entities,c) ){ return ('&'+entities[c]+';'); } return ''; }, showHelp = function() { sendChat('', '/w gm '+ '<div style="border: 1px solid black; background-color: white; padding: 3px 3px;">'+ '<div style="font-weight: bold; border-bottom: 1px solid black;font-size: 130%;">'+ 'SpinTokens v'+version+ '<div style="clear: both"></div>'+ '</div>'+ '<div style="padding-left:10px;margin-bottom:3px;">'+ '<p>Allows the GM to toggle spinning of selected tokens</p>'+ '</div>'+ '<b>Commands</b>'+ '<div style="padding-left:10px;"><b><span style="font-family: serif;">!spin-start '+ch('[')+'Seconds Per Cycle'+ch(']')+' '+ch('[')+'-- tokenID ...'+ch(']')+'</span></b>'+ '<div style="padding-left: 10px;padding-right:20px">'+ 'Starts a selected or specified tokens spinning, optionally with a speed.'+ '<ul>'+ '<li style="border-top: 1px solid #ccc;border-bottom: 1px solid #ccc;">'+ '<b><span style="font-family: serif;">Seconds Per Cycle</span></b> '+ch('-')+' Specifies the number of seconds for the token to make a full spin. <b>Default: '+defaultSecondsPerCycle +'</b></li>'+ '</li> '+ '</ul>'+ '</div>'+ '</div>'+ '<div style="padding-left:10px;"><b><span style="font-family: serif;">!spin-stop '+ch('[')+'-- tokenID ...'+ch(']')+'</span></b>'+ '<div style="padding-left: 10px;padding-right:20px">'+ 'Stops the selected or specified tokens from spinningb.'+ '</div>'+ '</div>'+ '</div>' ); }, handleInput = function(msg) { if ( "api" !== msg.type || !playerIsGM(msg.playerid) ) { return; } let parts = msg.content.split(/\s+--\s+/); let args = parts[0].split(/\s+/); let ids = (parts[1]||'').split(/\s+/).filter((id)=>id.length); switch(args[0]) { case '!spin-start': { if(!( msg.selected && msg.selected.length > 0 ) && ids.length===0) { showHelp(); return; } let secondsPerCycle = Math.abs(args[1] || defaultSecondsPerCycle); ids=[...new Set([...(msg.selected||[]).map((s)=>s._id), ...ids])]; ids.map((id)=>getObj('graphic',id)) .filter((o)=>undefined !== o) .forEach((o)=>{ state.SpinTokens.spinners[o.id]={ id: o.id, rotation: o.get('rotation'), page: o.get('pageid'), rate: (secondsPerCycle*millisecondsPerSecond) }; }) ; } break; case '!spin-stop': { if(!( msg.selected && msg.selected.length > 0 ) && ids.length===0) { showHelp(); return; } ids=[...new Set([...(msg.selected||[]).map((s)=>s._id), ...ids])]; ids.map((id)=>getObj('graphic',id)) .filter((o)=>undefined !== o) .filter((o)=>state.SpinTokens.spinners.hasOwnProperty(o.id)) .forEach((o)=>{ o.set('rotation',state.SpinTokens.spinners[o.id].rotation); delete state.SpinTokens.spinners[o.id]; }) ; } break; } }, getActivePages = () => _.union([ Campaign().get('playerpageid')], _.values(Campaign().get('playerspecificpages')), _.chain(findObjs({ type: 'player', online: true })) .filter((p)=>playerIsGM(p.id)) .map((p)=>p.get('lastpage')) .value() ), animateSpin = function() { var pages = getActivePages(); _.chain(state.SpinTokens.spinners) .filter(function(o){ return _.contains(pages,o.page); }) .each(function(sdata){ var s = getObj('graphic',sdata.id); if(!s) { delete state.SpinTokens.spinners[sdaC1d]; } else { if (state.SpinTokens.spinners[sdata.id].rotation >= 360) { state.SpinTokens.spinners[sdata.id].rotation = 0; s.set({ rotation: 0 }); } else { state.SpinTokens.spinners[sdata.id].rotation = state.SpinTokens.spinners[sdata.id].rotation + 10; s.set({ rotation: state.SpinTokens.spinners[sdata.id].rotation }); } } }); }, handleTokenDelete = function(obj) { var found = _.findWhere(state.SpinTokens.spinners, {id: obj.id}); if(found) { delete state.SpinTokens.spinners[obj.id]; } }, handleTokenChange = function(obj) { var found = _.findWhere(state.SpinTokens.spinners, {id: obj.id}); if(found) { state.SpinTokens.spinners[obj.id].rotation= obj.get('rotation'); } }, checkInstall = function() { log('-=> SpinTokens v'+version+' <=- ['+(new Date(lastUpdate*1000))+']'); if( ! _.has(state,'SpinTokens') || state.SpinTokens.version !== schemaVersion) { log(' > Updating Schema to v'+schemaVersion+' <'); state.SpinTokens = { version: schemaVersion, spinners: {} }; } setInterval(animateSpin,stepRate); }, registerEventHandlers = function() { on('chat:message', handleInput); on('destroy:graphic', handleTokenDelete); on('change:graphic', handleTokenChange); }; return { CheckInstall: checkInstall, RegisterEventHandlers: registerEventHandlers }; }()); on("ready",function(){ 'use strict'; SpinTokens.CheckInstall(); SpinTokens.RegisterEventHandlers(); });