Automating an adventure
Adventures are the part of Animal Jam that plays like an action game: things move and chase you, ground opens as you go, and the treasure is scattered about. They need more from the SDK than anything else.
Everything here goes through phantom.aj.adventures, which asks the game client to do what the player's clicks do. Nothing bypasses the server. It's split into lobby, actors, walk, treasure, rewards, crystals, combat, dialogue, scene, garden and routes.
Every read comes fresh from the client and every action is confirmed. An action that doesn't happen throws a PhantomError (REFUSED, TIMEOUT, NOT_READY or ABORTED) rather than returning false.
Careful
Playing an adventure automatically is at your own risk.
Joining
const stop = phantom.aj.adventures.onStart((start) => {
console.log(`started with ${start.actors.length} actors`);
stop();
});
try {
await phantom.aj.adventures.lobby.join(39, { difficulty: 0 });
} catch (error) {
console.log('refused:', error instanceof Error ? error.message : error);
}Don't treat the join's answer as proof you're in. For some adventures the server never sends one, so join times out while the game is already playing. The real test is the adventure starting, which onStart hears. Add that listener before you join, because it can fire before join returns.
Adventures are only listed while in season. lobby.list() gives what Animal Jam offers today, with each definition. An out-of-season adventure isn't listed, but the server still lets you join it by id, so here the number is more useful than the name.
Finding what's in the room
Use actors.find. It filters and sorts inside the client and returns everything about each actor in one answer.
const worth = await phantom.aj.adventures.actors.find({
kinds: ['placedTreasure', 'droppedTreasure', 'item', 'groupItem'],
visibleOnly: true,
reachable: true,
sort: 'path',
});name takes a pattern (proto*, *__give_orb, ptreasure?). An actor's id is the only name a plugin has for something it didn't place, and the adventure's author often put meaning in it. group collects everything named <group>__<n>, which is how the client itself groups actors.
An actor's position is usually null
Only actors the server spawns have a position. Everything the adventure placed, which is most of a room, gets its position from the room's scene and reads as null. Sorting on it gives every placed chest the same value. sort: 'path' and actors.position() ask the client where things really are.
Checking you can get there
const reach = await phantom.aj.adventures.walk.canReach('treasure_1');
if (reach.reachable) await phantom.aj.adventures.walk.to('treasure_1');
else if (reach.point) await phantom.aj.adventures.walk.toPoint(reach.point);Check before you walk. Given somewhere it can't reach, the client's pathfinder walks the path to the nearest point it can find, so a hopeless walk looks like a good one until the avatar is stuck against the scenery. canReach plans over the room's grid first and says reachable, walled-off, off-grid or no-grid. point is the nearest place the player can get to.
walk.plan does the same work and returns the route:
const plan = await phantom.aj.adventures.walk.plan('treasure_1');
console.log(`${Math.round(plan.distance ?? 0)}px, ${plan.route.length} cells`);
if (plan.verdict === 'walled-off' && plan.blocked === 'phantom') {
}The search is Phantom's own, over the room's grid, with no distance limit. The client's search is limited: RoomGrid.getNeighbors refuses every cell more than GRID_DEPTH_LIMIT from the start on either axis, and adventures cut that to 25, so in the largest rooms the client only sees about fifty cells around itself. blocked says why a walk falls short, from what the target's cell holds: wall for scenery, air for a ceiling you'd have to fly under, phantom for the cells an adventure's gates and closed volumes are stamped from, and null for open ground that's shut off behind something else.
Covering a room
const ids = (await phantom.aj.adventures.treasure.collectables()).map(
(actor) => actor.actorId
);
const round = await phantom.aj.adventures.walk.order(ids);
for (const actorId of round.order) {
await phantom.aj.adventures.walk.to(actorId);
await phantom.aj.adventures.treasure.collect(actorId);
}Don't sort targets by distance from the player. Once the nearest is taken, the second nearest is usually behind you, so the run crosses the room and back. walk.order measures each leg from where the last one ended, by real path cost over the grid.
Targets that can't be reached go in unreachable instead of being sorted to the end. Otherwise they'd still be walked to eventually, costing a walk and a timeout to learn something the room could have told you up front.
How close is close enough
Don't guess a pixel distance. The client has its own, and it's more generous than you'd expect.
const reach = await phantom.aj.adventures.actors.reach('treasure_1');
if (reach && !reach.inReach)
await phantom.aj.adventures.walk.to('treasure_1', { within: reach.click });
await phantom.aj.adventures.treasure.collect('treasure_1');click is how far away the game still accepts a click on it: its own radius plus about an avatar and three quarters. at is the point to aim at, which is not the actor's position. Most actors' art hangs above their origin, so a click at the bare position misses.
Chasing something that moves
const chase = await phantom.aj.adventures.walk.chase('phantom_1', {
strike: true,
untilDefeated: true,
});This follows the actor inside the client rather than reading where it was and walking there. It stops at the actor's reach, and strike only swings when the client will accept it, so nothing is wasted on the weapon's timer. A chase that can't get there throws NOT_READY (no way there) or TIMEOUT (out of time).
It also aims ahead of the actor. A follow knows the client's speed for the actor (QuestActor._seekVelocity, which the client negates for anything running away from the player) and its direction, so each walk is planned to where the two paths meet. If you walk at a fleeing phantom's current position you never catch it: by the time you arrive it's gone, and the walk is planned again from somewhere it's already left.
walk.to does the same with lead: true, if you want the walk without the rest of the chase.
A click's reach is not a touch
Some adventures are won by walking into things, and the two distances are nearly forty pixels apart. A click lands from collisionRadiusMoving + 1.75 avatars away; contact is collisionRadiusMoving + 1 avatar (QuestManager.actorCollisionTest). A chase that stops at click reach hasn't touched anything, the server hears nothing, and the actor stays put while the run marks it done.
const [phantomActor] = await phantom.aj.adventures.actors.find({ kinds: ['attacker'] });
const caught =
phantomActor &&
(await phantom.aj.adventures.walk.chase(phantomActor.actorId, {
strike: false,
touch: true,
}));With touch, the chase closes in until the player is inside the touch circle and the client agrees for that frame. It aims to put the player's own hit point on the actor, since every distance in QuestActor is measured from that point.
Finding the boss
Animal Jam has no boss flag. An NPC definition has a level, hit points and an xp value, and nothing that says "boss". So:
const big = await phantom.aj.adventures.actors.biggest();
if (big) console.log(`${big.name ?? big.actorId}, level ${big.npc?.level}`);It ranks by level, then hit points, then xp value, and falls back to the nearest when every phantom in the room is the same definition. name comes back translated, and it's the only place an actor's real name appears.
What can reach you
for (const danger of await phantom.aj.adventures.actors.danger()) {
if (!danger.clear)
console.log(`${danger.actor.actorId} is ${danger.slack}px from noticing you`);
}slack is how many pixels you have before it notices you, so the most dangerous comes first. hidden is the client's own answer to whether you're in a stealth volume, where none of this applies.
What you won
treasure.onReward tells you what you got, once per thing, from every source: a goal's chest, dropped treasure, an item the script gave you, a screen gift, an award.
phantom.aj.adventures.treasure.onReward((reward) => {
console.log(reward.name ?? reward.kind, reward.kept ? 'kept' : 'turned down');
});The item's name is already looked up. goal says which milestone's chest it was, counting from one.
Tip
Only items can be turned down. Gems and crystals are kept whichever button is pressed, because both of the game's buttons keep them.
Taking the chests
In an adventure with crystal goals, a screen comes up as soon as each goal is reached, mid-run, and darkens everything behind it. Nothing can walk until its treasures are taken and it closes, so you have to handle it as you go.
const taking = await phantom.aj.adventures.rewards.autoTake({
keep: ['denItem', 'clothing'],
});That's all you need if you don't mind which chest is which. To decide for yourself, by item name or anything else you need to ask the client for, watch the screen and press instead:
await phantom.aj.adventures.rewards.onScreen(async (screen) => {
for (const slot of screen.slots) {
if (!slot.claimable) continue;
const worthKeeping = slot.kind === 'denItem' || slot.kind === 'clothing';
await phantom.aj.adventures.rewards.press(slot.index, { discard: !worthKeeping });
}
});Press the screen, don't just tell the server
rewards.choose only sends the pick. The screen doesn't know the chest was taken, so it stays up with its treasures still waiting and never closes. rewards.press presses the treasure like a player, waits for the gift popup that opens and answers it, which is what sends the pick. The client then redraws the screen and closes it after the last one.
claimable is the client's own answer, recorded as it drew the screen (QuestManager._adventureRewardsPopupStatus). Don't work it out from the goals: they're read slightly behind the client, so a chest it just unlocked reads as locked. rewards.claimable(offer) gives the same answer for an offer from rewards.onOffer, and only falls back to the goals when there's no screen to ask.
Watching instead of polling
Polling an adventure over the bridge is slow and jittery. Watch instead:
const following = await phantom.aj.adventures.actors.follow('phantom_1', (actor) => {
if (actor.gone) return;
console.log(actor.distance, actor.inReach ? 'in reach' : 'still coming');
});actors.watch does the same for the whole room, and dialogue.onBlocking for whatever is blocking the player. All three are restored for you when the game reloads; see Timing.
Clearing the popups
An adventure shows dialogue before the player can move, and more as it goes.
Set this up before you join. The opening lines appear while the room is still being built, and a watcher added afterwards finds the popup already up with nobody to press Next.
To find out what's in the way, dialogue.blocking() names it: dialogue, goals, gift, or darkened for one of the client's own popups, which phantom.aj.popups.close closes.
What the client works out itself
Much of an adventure never goes over the network. The server says a phantom moved, and the client works out the rest: whether it has noticed you, which node of the room's path it's heading for, where its next step lands, whether its shot is still in the air. The packets don't tell you any of that.
const detail = await phantom.aj.adventures.actors.inspect('phantom_1');
if (detail) {
console.log(
detail.internals.alerted
? 'it has noticed you'
: `walking to node ${detail.patrol.node}`
);
}inspect returns everything one actor is doing at once. internals says whether it's alerted, whether it's colliding, which player it's swinging at and what the adventure overrode on it. patrol is its route, node by node, with the node it's heading for now. view is the NPC body it's drawn with (see below).
actors.follow reports the moving parts on the game's own frames: seek, speed and velocity come with every reading.
Projectiles
const air = await phantom.aj.adventures.combat.inFlight();
for (const shot of air.projectiles) {
if (!shot.byPlayer)
console.log(`incoming, ${shot.range - shot.travelled}px of range left`);
}Nothing on the network says where a shot is. The server is told a weapon was launched, and told again if the client counts a hit. In between, the client moves it frame by frame. combat.clearInFlight removes them all the same way the client does for a miss.
The actor's body
An actor has two halves: the QuestActor the server describes, which actors.inspect and actors.find read, and the NPCView the client draws it with, which no packet mentions. The fight happens in the second half: whether the wind-up has reached the frame the hit goes out on, and the circles the client measures it by.
const detail = await phantom.aj.adventures.actors.inspect('phantom_1');
if (detail?.view?.readyToAttack === false)
console.log('it is mid swing, stay out of reach');
console.log(
'state',
detail?.view?.state,
'attack radius',
detail?.view?.collision.attackRadius
);view.state is NPCView._npcState: -2 loading, -1 loaded, 0 idle, 1 moving, 2 attacking, 4 dying, 8 dancing.
Going beyond the area
The area covers what a run needs. For anything else, use the client directly through phantom.flash, typed from the client's declarations. actors.place(id) gives you an actor's QuestActor, and phantom.flash.use('quest.QuestManager') gives you the adventure.
An actor has two kinds of number: what the client works out every frame, which a write can't hold, and what the adventure set once and nothing writes again. The second kind is writable, and it's most of what makes a phantom dangerous.
const actor = await phantom.aj.adventures.actors.place('phantom_1');
if (actor) {
await actor._actorDefaultInnerRadius.$set(40);
await actor._actorDefaultOuterRadius.$set(300);
await actor._setSeekVelocity.$set(30);
await actor._targetRangeMin.$set(0);
await actor._targetRangeMax.$set(0);
}One of these does reach Animal Jam, through the walk rather than the write. For an actor the client is walking itself, it reports the speed it used and the server trusts it, so a slower phantom is slower for everyone in the room.
The body it's drawn with is _npc, and its calls draw the same way the game does:
const actor = await phantom.aj.adventures.actors.place('phantom_1');
if (actor) {
await actor._npc.setNpcState(8);
await actor._npc.moveFaceAnim(180);
await actor._npc.setSwfState('dance');
}None of that is sent, and the server still decides what a phantom does.
The run itself is QuestManager's fields. status() reads the common ones (paused, exit pending, the crystal count), and the rest are a $set away:
const quest = phantom.flash.use('quest.QuestManager');
await quest._paused.$set(true);
await quest._playerWeaponTimerProjectile.$set(0);
await quest._paused.$set(false);_paused stops everything: the adventure's frame handler does nothing while it's set, so no phantom moves, no timer runs and no cutscene advances. The client catches up in one frame when it's cleared. For a value the client writes back every frame, use $keep instead of $set.
What can't be written
Some things are read-only on purpose:
- An actor's seek, alert and collision are worked out again every frame, so a write is gone within forty milliseconds.
- The crystal, gem and experience totals are what the reward screen shows, but the server keeps its own count and checks every pick against it. Writing them changes what the player sees, not what they get.
- A room path's points belong to the room's scene, and every actor on that path shares the same object, so moving one node moves them all.
- The list of players a phantom's swing still has to test should be left alone, even though nothing recalculates it. The client is what tells the server a phantom hit the player, so emptying the list would mean damage the server never hears about. That's a hole in the game, not a change to it.
- A damage number or a goal's progress would show something that didn't happen, and the next packet corrects it.
Hearing the client
Every other listener here is a packet, and a packet arrives whether or not the client acts on it. onClientStep hears the client itself: taking the message in the frame it took it, and the many things it does that no message describes.
const stop = phantom.aj.adventures.onClientStep('spawn', (call) => {
console.log('the client placed an actor', call.argList);
});
const frames = phantom.aj.adventures.onClientStep(
'frame',
() => console.log('a second of them'),
{
every: 24,
}
);The steps are grouped the way QuestManager is:
- the fight:
playerAttacked,actorDeath,weaponSwung,award - the room and scene:
actorRoster,sceneAssets,roomChange,roomMissing - the screen:
fadeIn,shakeStarted,cutscenePlayed,voiceOverEnded,assetPooled - screens the player answers:
giftOffered,rewardPressed,rewardKept,leaveLobbyAsked - art the client fetches:
dialogueLoaded,timerLoaded,goalLoaded - the new player experience:
newPlayerSkipped,newPlayerShopOpened - the actor's body:
npcState,npcHitText,npcBuilt
CLIENT_STEPS names the client method behind each one.
frame is the adventure's heartbeat and runs 24 times a second, so give it every and keep the listener short.
Left out on purpose
Some of QuestManager's calls are reachable but not offered, because they ask Animal Jam for something nobody earned:
handleAward(awardId, amount)goes straight toAchievementXtCommManager.requestSetUserVar, which writes a user var on the account. You can hear it asonClientStep('award'), but nothing calls it._handlePickGiftDelayDatais what the heartbeat passes tohandlePickGiftwhen its timer runs out, so writing it builds a prize screen the server never sent.currencyUpdateis only the client's own count of gems earned this run, which labels the reward screen. You can hear it asonClientStep('gemsTallied'), but it isn't offered as a call: a number nobody earned would mislead the player, and the real gems are the server's.
Example plugins
Two plugins in the repo do all of this for real and are worth reading before you write your own: plugins/examples/auto-tfd plays The Forgotten Desert, and plugins/bitter-sweets plays Bitter Sweets by touching Proto-Phantoms.