Watching and changing what the game does
A hook goes on one of the client's methods. It can watch calls to it, change the arguments, answer in its place or stop it running. Phantom removes a plugin's hooks when the plugin is turned off or uninstalled.
const stop = await phantom.flash.hook(
'room.RoomManagerWorld.loadRoom',
{ capture: { args: [0] } },
(call) => console.log('loading', call.argList[0])
);Which one to use
From least to most change:
| What you want | Use |
|---|---|
| To know a method ran | phantom.flash.hook(name, { capture }, listener) |
| To change its arguments or its answer | phantom.flash.hook(name, { when, replaceArgs, replaceReturn }) |
| To replace it with your own function | phantom.flash.override(name, handler) |
| To know a screen opened | phantom.aj.ui.onScreen(name, listener) |
| To hear one of its events | phantom.aj.ui.place(place).on(event, listener) |
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const stop = await phantom.flash.hook(
'gui.AvatarEditor.onColorChanged',
{ capture: { args: true } },
(call) => console.log('grid', call.argList[0], 'picked colour', call.argList[1])
);
return () => void stop();
},
});A hook's rules run inside the call, in the game, so they can change what it does. The listener runs afterwards and can't change anything. An override is the other way round: your function replaces the method, and call.original() runs the client's own code, with the same arguments or different ones. See the game client reference.
What an event was about
Most of what a player does reaches the client as a mouse event, and the client's handlers mostly look like winMouseDown(evt), with the thing pressed in evt.currentTarget. If you capture the event and read it afterwards you'll often find nothing there, because the client has already torn the window down.
So name what you want from the argument and it's read inside the call. The first step of each path is the argument, by name or index, and the rest is a path from it.
const stop = await phantom.flash.hook(
'gui.PetInventory.winMouseDown',
{
capture: {
args: {
slot: '0.currentTarget.index',
pet: '0.currentTarget._currItem.name',
defId: '0.currentTarget._currItem.defId',
},
},
},
(call) => console.log('slot', call.args.slot, 'is', call.args.pet, call.args.defId)
);Each value arrives in call.args under the name you gave it. A path that can't be followed is null, and an object comes back as { $object: 'pet::PetItem' }, which tells you what kind of thing it was.
You rarely need to write this yourself. phantom.aj.guiItems.onPress already does it for every grid in the client, and the areas have typed events for their own windows. See Hearing the player.
Only when something changed
A hook on a method the client runs every frame fires every frame, and most of those calls are about a value that hasn't moved. Filtering them out in the listener still costs a message each.
Put the changed comparison in the rules instead, so it's decided in the game. It holds when the value differs from the last time it was read, and the first read counts as a change. Objects compare by identity, so the client building a window again counts as a change and a field moving inside it doesn't. Use it in a hook's when through phantom.flash.bridge.hook, or in a wait through phantom.flash.bridge.when. See Reaching the game client.
Overrides
The argument order is the opposite of hook. A hook's listener is optional and an override's handler isn't, so the handler comes second.
await phantom.flash.hook('gui.ChatHistory.addMessage', { first: 10 }, (call) => {});
await phantom.flash.override('gui.ChatHistory.addMessage', async (call) => {}, {
first: 10,
});Get it the wrong way round and it throws, saying which one you wrote.
The arguments
The handler gets the call's arguments three ways. They're the same values.
| How | When to use it |
|---|---|
call.argList | destructuring in order: const [name, text] = call.argList |
call.args.userName | by the name Animal Jam's own source gives the parameter |
call.arg('userName') | the same, checked: a name the method hasn't got throws and lists them |
call.args is an object, not a list, so const [a, b] = call.args won't work. TypeScript flags it, and if you cast past that, the runtime error points you to call.argList. phantom.flash.describe and phantom.flash.search both print the parameter names.
Ordering and dropped calls
Handlers run one at a time, in the order the calls came, so their original() calls reach the client in that order too. Without this, anything the client appends in order (chat lines, mainly) comes out shuffled. Pass sequential: false where order doesn't matter and the handler waits on something.
An override catches up to 200 calls a frame, the most the game will post. Calls over that run as if the override weren't there. In a busy trade room this looks like the plugin half working: one line changed, the next not. Phantom warns on the console once when it happens, naming the override. Pass onDropped to count them yourself instead.
const stop = await phantom.flash.override(
'gui.ChatHistory.addMessage',
async (call) => {
const [animal, account, moderated, message] = call.argList;
await call.original(animal, account, moderated, `${message}`.toUpperCase());
},
{ onDropped: (dropped) => console.log(dropped, 'lines came too fast to change') }
);A hook's listener drops calls the same way past its own perFrame, which is 20, and warns once. A hook's rules never drop calls, because they run inside every call.
Cleanup
A plugin can be turned off, uninstalled or reloaded at any time, and its own cleanup might never run. Phantom undoes its changes to the client anyway, in two steps:
- Anything the plugin's frame holds in the client is dropped. Each frame gets its own range of ids, and every overlay node, hook, override, watch, UI rule, highlight, hidden object and held handle it makes is tagged with one. When the frame goes, Phantom sends the client one
disposefor that range. - The plugin's own cleanup runs. Calls that change the client's state without holding anything there return a function that undoes them.
aj.player.avatar.colors.offerrefills one of the avatar editor's colour grids and returns the function that puts the grid's own colours back. Return it fromsetupand it runs when the plugin is turned off, uninstalled or reloaded.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const { aj } = ctx.phantom();
const offer = await aj.player.avatar.colors.offer('color1');
console.log(`the grid offers ${offer.count} colours`);
return () => offer();
},
});What isn't undone
- Values you write straight to the client with
$seton the typed client. Phantom never read the old value, so it can't put it back. If it matters, read it first and write it back yourself. - Anything sent to Animal Jam. If the player picked a colour and the editor sent it, that's a real change to their account. Putting the grid back doesn't undo it.
- Another plugin's changes. Each plugin only cleans up its own.