
UPDATE: A new version of this script has been posted here.
My first attempt at an API script, thought I'd share, and possibly get some feedback/suggestions on this.
Create a path using any token's last move. For each patrolling token, add a comma-delimited string into the GM Notes section.
Example: patrol,SamplePath,0,0,12,0
GM Note Comma-Delimited String:
Commands
Debugging when LOGOUTPUT is true.

My first attempt at an API script, thought I'd share, and possibly get some feedback/suggestions on this.
Create a path using any token's last move. For each patrolling token, add a comma-delimited string into the GM Notes section.
Example: patrol,SamplePath,0,0,12,0
GM Note Comma-Delimited String:
- patrol: always the same
- pathname: the saved path name
- direction: determines how the order of points is followed 1=foward, -1 backwards, 0=forward then backward
- nextpoint: offset point in path, 0 starts at the first waypoint
- speed: movement rate of the token. speed * 10 = yards moved in 60 seconds
- rotation: addition rotation of token to face waypoint. -1 = do not face waypoint
Commands
- !WaypointAdd <pathname> - saves the last move of selected target as <pathname>
- !WaypointDelete <pathname> - deletes the saved path named <pathname>
- !WaypointList - displays in chat all saved waypoint path names
- !TogglePatrol - turns patrolling on and off
- !ToggleAllPages - sets if patrol should occur on all pages, or just the current campaign page
- !TogglePaths - sets if the path of tokens should be displayed. if disabled, tokens will move straight to the final destination
- !ToggleLogs - turns console logging on and off
Debugging when LOGOUTPUT is true.

state.WayPointPatrol = state.WayPointPatrol || {} var REFRESHRATE = 5 // in seconds var ENABLEPATROL = true // default action on script load var ACTIVEPAGEONLY = true // only patrols token on active page var SHOWPATHS = true // show the path taken when true var LOGOUTPUT = false // log calculations in output console //order of gm notes var csv = {} csv.patrol = 0 csv.path = 1 csv.direction = 2 csv.nextpoint = 3 csv.movementrate = 4 csv.rotation = 5 on ("chat:message", function(msg) { var cmdName = "!WaypointList"; if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1 ) { // list all the saved names in state if (Object.keys(state.WayPointPatrol).length == 0) { sendChat("","/desc No Path Names in State"); } else { sendChat("","/desc Path Names: " + Object.keys(state.WayPointPatrol).join(",")); } } }); // !WaypointList on("chat:message", function(msg) { var cmdName = "!WaypointDelete "; if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1 ) { //removespaces from path name var pathName = msg.content.replace(cmdName,"").split(" ").join(""); //delete the property in state delete state.WayPointPatrol[pathName]; sendChat("","/desc Path [" + pathName + "] deleted."); } }); // !WaypointDelete on("chat:message", function(msg) { var cmdName = "!WaypointAdd "; if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1 ) { //verify only one target selected try {if (msg.selected.length !== 1) { sendChat("","/desc Err: Multiple targets selected") return; } } catch(err) { sendChat("","/desc Err: No target selected") return; } //remove spaces from waypoint path name and get target token var pathName = msg.content.replace(cmdName,"").split(" ").join(""); var pathToken = getObj("graphic",msg.selected[0]._id); //exit if there is no last move in token if (pathToken.get("lastmove") == "") { sendChat("","/desc Err: Selected has no last move") return; } //read lastmove into an array var pathPoints = pathToken.get("lastmove").split(","); //add path name to state state.WayPointPatrol[pathName] = {}; //save lastmove in state state.WayPointPatrol[pathName].WayPoints = []; var numOfPoints = pathPoints.length / 2; for (var i = 0; i < numOfPoints; i++) { state.WayPointPatrol[pathName].WayPoints[i] = {}; state.WayPointPatrol[pathName].WayPoints[i].Left = parseInt(pathPoints[2*i]); state.WayPointPatrol[pathName].WayPoints[i].Top = parseInt(pathPoints[2*i+1]); } //save current position in state state.WayPointPatrol[pathName].WayPoints[numOfPoints] = {}; state.WayPointPatrol[pathName].WayPoints[numOfPoints].Left = parseInt(pathToken.get("left")); state.WayPointPatrol[pathName].WayPoints[numOfPoints].Top = parseInt(pathToken.get("top")); //save the page id for path state.WayPointPatrol[pathName].PageID = pathToken.get("_pageid"); //clear the lastmove from the token to keep from adding the same path twice pathToken.set("lastmove",""); //send notification of save sendChat("","/desc Path [" + pathName +"] added"); if (LOGOUTPUT) { log("Path Added [" + pathName + "]"); log("- PageID: " + state.WayPointPatrol[pathName].PageID); log("- Waypoints: " + state.WayPointPatrol[pathName].WayPoints.length); } } }); // !WaypointAdd on("chat:message", function(msg) { var cmdName = "!ToggleLogs"; if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1) { // enable/disable Console Logging LOGOUTPUT = !LOGOUTPUT; var status = LOGOUTPUT ? "Enabled" : "Disabled"; sendChat("","/desc Console Logging " + status); log("Console Logging " + status); } }); // !ToggleLogs on("chat:message", function(msg) { var cmdName = "!TogglePatrol"; if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1) { // enable/disable patrol ENABLEPATROL = !ENABLEPATROL var status = ENABLEPATROL ? "Enabled" : "Disabled"; sendChat("","/desc Waypoint Patrol " + status); if (LOGOUTPUT) log("Waypoint Patrol " + status); } }); // !TogglePatrol on("chat:message", function(msg) { var cmdName = "!TogglePaths"; if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1) { // enable/disable paths SHOWPATHS = !SHOWPATHS var status = SHOWPATHS ? "Shown" : "Hidden"; sendChat("","/desc Waypoint Paths " + status); if (LOGOUTPUT) log("Waypoint Paths " + status); } }); // !TogglePaths on("chat:message", function(msg) { var cmdName = "!ToggleAllPages"; if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1) { // enable/disable patrol ACTIVEPAGEONLY = !ACTIVEPAGEONLY var status = ACTIVEPAGEONLY ? "Current Page" : "All Pages"; sendChat("","/desc Patrol Set To " + status); if (LOGOUTPUT) log("Patrol Set To " + status); } }); // !ToggleAllPages on("ready", function() { setInterval(function() { //exit if patrol is turned off if (!ENABLEPATROL) return; //record start time for performance tracking if (LOGOUTPUT) { log("===BEGIN WAYPOINT PROCESS==="); var timeStart = +new Date(); } //loop for all tokens on object layer if (ACTIVEPAGEONLY) { var allTokens = findObjs({_type:"graphic", layer:"objects", _pageid:Campaign().get("playerpageid")}); } else { var allTokens = findObjs({_type:"graphic", layer:"objects", }); } _.each(allTokens, function(obj) { //read the gmnotes from the token, correct for formatting var gmn = obj.get("gmnotes").replace(/\%2C/g,",").split(","); //skip token if not flagged for patrol or token is dead try { if (gmn[csv.patrol] != 'patrol' || obj.get("status_dead") != false) return; } catch (err) { return; } if (LOGOUTPUT) log("MOVING TOKEN: " + obj.get("name")); //skip if path not for page token is on or if state data doesn't exist try{ var thisPath = state.WayPointPatrol[gmn[csv.path]]; if (thisPath.PageID !== obj.get("_pageid")) { log("- Err: Path [" + gmn[csv.path] + "].PageID does not match obj.pageid"); return; } } catch (err) { log("- Err: Path [" + gmn[csv.path] + "] not in state"); return; } if (LOGOUTPUT) log("- Following path ["+ gmn[csv.path] +"]"); //read direction try { gmn[csv.direction] = parseInt(gmn[csv.direction]); } catch (err) { gmn[csv.direction] = 0; } //copy path and format based on direction thisPath = thisPath.WayPoints.slice(0); if ( gmn[csv.direction] == 1 ) { //loop waypoints forwards //do nothing, waypoints are loaded forward by default } else if (gmn[csv.direction] == -1) { //loop waypoints backwards thisPath.reverse(); } else { //loop waypoints forwards then backwards gmn[csv.direction] = 0; var thatPath = thisPath.slice(0); thatPath.reverse(); thatPath.pop(); thatPath.shift(); thisPath = thisPath.concat(thatPath); } //read next waypoint try { gmn[csv.nextpoint] = parseInt(gmn[csv.nextpoint]); if (gmn[csv.nextpoint] >= thisPath.length) gmn[csv.nextpoint] = 0; } catch (err) { gmn[csv.nextpoint] = 0; } //read movement rate try{ gmn[csv.movementrate] = parseInt(gmn[csv.movementrate]); //ADnD 2E: movement rate for human is 12 if (gmn[csv.movementrate] <= 0) csv.movementrate = 12; } catch (err) { gmn[csv.movementrate] = 12; } //read rotation try { gmn[csv.rotation] = parseInt(gmn[csv.rotation]); if (gmn[csv.rotation] < 0) gmn[csv.rotation] = -1; } catch (err) { gmn[csv.rotation] = 0; } //determine the maximum movement in pixels //ADnD 2E: movement rate * 10 = the number of yards token can move in 1 round out of combat //assumes map distance is measured in feet var remainingDistance = gmn[csv.movementrate] * 35 * REFRESHRATE / getObj("page",obj.get("_pageid")).get("scale_number"); if (LOGOUTPUT) log("- Max Distance: " + remainingDistance + " pixels"); //set referenece position for calculations in distance refLeft = parseInt(obj.get("left")); refTop = parseInt(obj.get("top")); if (LOGOUTPUT) log("- Starting from " + refLeft + "," + refTop); //set the waypoint movement of token to current location obj.set("lastmove", refLeft + "," + refTop); //move token as long as remainingDistance > 0 do { //get the next waypoint and calculate distance wayLeft = thisPath[gmn[csv.nextpoint]].Left; wayTop = thisPath[gmn[csv.nextpoint]].Top; var distanceToWayPoint = Math.sqrt(Math.pow(wayLeft-refLeft,2) + Math.pow(wayTop-refTop,2)); //to prevent an infinite loop var loop = 0; if (remainingDistance > distanceToWayPoint && loop < 10) { //add waypoint to movement and subtract distance if (SHOWPATHS) obj.set("lastmove", obj.get("lastmove") + "," + wayLeft + "," + wayTop); remainingDistance -= distanceToWayPoint; if (LOGOUTPUT) log("- Next Point: " + wayLeft + "," + wayTop + " distance: " + distanceToWayPoint.toFixed(1) + " remaining: " + remainingDistance.toFixed(1)); //use waypoint as the next reference point refLeft = parseInt(wayLeft); refTop = parseInt(wayTop); //set for next waypoint, reset as needed gmn[csv.nextpoint] = gmn[csv.nextpoint] + 1; if (gmn[csv.nextpoint] >= thisPath.length) gmn[csv.nextpoint] = 0; obj.set("gmnotes", gmn.toString()); //increment loop counter loop += 1; } else { //calculate ratio of last movement var moveRatio = remainingDistance / distanceToWayPoint; //set the final position refLeft = parseInt(moveRatio * (wayLeft - refLeft)) + refLeft; refTop = parseInt(moveRatio * (wayTop - refTop)) + refTop; //make sure final position is within the boundries of the map var mapWidth = getObj("page",obj.get("_pageid")).get("width") * 70; var mapHeight = getObj("page",obj.get("_pageid")).get("width") * 70; refLeft = Math.min(Math.max(refLeft,0), mapWidth); refTop = Math.min(Math.max(refTop,0), mapHeight); if (LOGOUTPUT) { log("- Last Point: " + wayLeft + "," + wayTop + " distance: " + distanceToWayPoint.toFixed(1)); log("- Move Ratio: " + (moveRatio*100).toFixed(2) + "% of " + distanceToWayPoint.toFixed(1)); log("- Move To: " + refLeft + "," + refTop); } //position token obj.set("left", refLeft); obj.set("top", refTop); if (LOGOUTPUT && SHOWPATHS) log("- lastmove: " + obj.get("lastmove")); //rotate token to face final waypoint if (gmn[csv.rotation] != -1) { var angleRotation = Math.atan2(wayTop - refTop, wayLeft - refLeft) * 180 / Math.PI - 90 + gmn[csv.rotation]; obj.set("rotation", angleRotation); if (LOGOUTPUT) log("- rotation: " + angleRotation.toFixed(1)); } //force the token to stop moving remainingDistance = 0; } } while (remainingDistance > 0); }) if (LOGOUTPUT) { var timeProcess = parseInt(+new Date() - timeStart) / 1000; log("Waypoint Process Time: " + timeProcess.toFixed(3) + " seconds"); } }, REFRESHRATE * 1000); });