Timing
When a plugin half works, the cause is usually timing. Nothing throws: the call lands on something the client has already thrown away, or just before the thing exists, and the plugin looks like it's on but does nothing.
Why it goes wrong
The client runs in frames. About 24 a second, fewer when the window is hidden or the computer is busy. A 250 millisecond wait is six frames on a good day and one on a bad one.
Everything is null until the client builds it. The HUD before play starts, a window before it opens, a shop's list before the server answers. Nothing tells you when.
The client rebuilds things. The HUD is rebuilt for every room, a window each time it opens and an animal each time it's drawn. Your listener stays on the old button, which is no longer on screen.
Every call crosses a bridge. A read or a write is a message each way. By the time yours arrives the client has run a frame or two, and anything it sets every frame has been overwritten.
Which one to use
| What you want | Use |
|---|---|
The game ready before setup runs | requires, or ctx.ready.wait (Waiting for the game) |
| The room finished building | phantom.aj.room.loaded() and phantom.aj.room.onLoaded |
| The HUD built again | phantom.aj.hud.onRebuild |
| A window to open | phantom.aj.screens.waitFor, phantom.aj.popups.waitFor, phantom.aj.gui.waitFor |
| A listener that survives the next room | phantom.aj.gui.on(path, event, listener, { keep: true }) |
| To do something to each new button, label or window | phantom.aj.gui.onBuilt, or phantom.aj.ui.place(...).whenever |
| To wait a few frames | phantom.flash.frames(count) |
| To wait for a value in the client | phantom.flash.until(place) |
| To keep something on an object the client rebuilds | phantom.flash.whenever(place, attach) |
| A value the client keeps putting back | phantom.flash.hold |
| A method called every frame, in the game | phantom.flash.repeat |
| What a value was at the moment of a call | a hook's capture (Hooks) |
The first six are area calls. The rest are phantom.flash, for when no area covers what you're waiting for.
Don't sleep
A fixed wait after onJoin is the most common bug in plugins that don't work. onJoin fires on the server's message, before the client has built the room. The first line below is the bug, and the second is the fix:
phantom.aj.room.onJoin(() => setTimeout(() => void decorate(), 1000));
phantom.aj.room.onLoaded(() => void decorate());
async function decorate(): Promise<void> {
await phantom.aj.hud.setLabel('land', 'Phantom is watching');
}The same goes for a window you've asked the client to open. Wait for the window, not for a length of time.
await phantom.aj.screens.open('denSwitcher', { timeoutMs: 5000 });
console.log('the den switcher is up');Listeners that survive the next room
You listen for a click on a HUD button, it works, the player goes through a door and it never fires again. The client built a new HUD for the new room and your listener is on the old button.
keep fixes this. The listener moves to each new button the client builds at that path and comes off the old one. It also waits for a path that doesn't exist yet, so you can set it up before the HUD is drawn.
const stop = phantom.aj.gui.on(
'hud.denBtn',
'click',
() => console.log('the den button was pressed'),
{ keep: true }
);phantom.aj.gui.onText and phantom.aj.gui.onVisible take keep too.
For things other than listeners, like a part you hid or a label you changed, onBuilt fires for each new element so you can do it again. onGone fires when the old one goes.
phantom.aj.gui.onBuilt('hud.zoneName', async () => {
await phantom.aj.gui.type('hud.zoneName', 'Somewhere nice');
});A new element is detected by the object itself, not its name or class, so a HUD rebuilt with every name the same is still noticed.
For a HUD label, phantom.aj.hud.keepLabel does all of this for you. It keeps your text through new rooms and HUD rebuilds until you call the function it returns.
Anything in the client
keep is built on phantom.flash.whenever. The function you return from it undoes your work, and Phantom calls it when the object is replaced, when it goes and when you stop. It takes any place in the client, or a function that finds one for an object with no path to it.
const nameBar = phantom.flash
.use('avatar.AvatarManager')
.playerAvatarWorldView.$at('_namebar');
const stop = await phantom.flash.whenever(nameBar, async (bar) => {
await bar.$at('mouseEnabled').$set(true);
return bar.$listen('mouseDown', () => console.log('you pressed your own name'));
});It checks about twice a second, and straight away when a room or the HUD has been built. If you know which client method builds the thing, name it in moments and it checks then too:
const editor = phantom.flash.use('gui.GuiManager').$at('avatarEditor');
await phantom.flash.whenever(editor, () => console.log('the editor is up'), {
moments: ['gui.AvatarEditor.init'],
onGone: () => console.log('and gone again'),
});When the game reloads
Phantom reloads the game's page when the player switches server and when a dropped connection is picked back up. The new loader knows nothing about the ids the old one handed out.
Most things are put back for you. Hooks, watches, drives, tracks, keys and event listeners are re-registered with the new loader, so your stop function still stops the right thing and your listener carries on. You don't need to do anything.
Three things can't come back:
- Anything aimed at a handle. A handle names an object the old loader held, and that object is gone. A watch, hook or track pointed at one is dropped.
- What you drew. Overlay nodes and anything you styled or highlighted go with the page.
- Note tables. What you kept in
phantom.flash.notesis lost.
Each of these is reported. To hear about it, or to set your own things up again, use onReload, which fires once the new client is up.
const stop = phantom.flash.onReload(async () => {
await phantom.flash.overlay.text('Back on', { x: 20, y: 20 });
});For anything long-running, put everything your plugin sets up in one function and call it again from onReload. It costs nothing when there's nothing to redo, and it keeps the plugin working after a server switch.
Tip
onTick, frames, nextFrame and whenever already carry on by themselves.
Waiting for a value
phantom.flash.until waits for a place in the client to hold something. Without a test function the whole wait happens inside the game: one message out and one back, when it holds or when time runs out.
const hud = phantom.flash.use('gui.GuiManager').mainHud;
if (!(await phantom.flash.until(hud, { timeoutMs: 30_000 }))) {
console.log('no HUD after half a minute');
}Pass a test when "not null" isn't what you mean. Several places are read in the same frame, so the values are consistent.
const view = phantom.flash.use('avatar.AvatarManager').playerAvatarWorldView;
const arrived = await phantom.flash.until(
{ x: view.x, y: view.y },
({ x, y }) => Math.abs(Number(x) - 400) < 10 && Math.abs(Number(y) - 300) < 10
);It returns null when time runs out rather than throwing, so check the result.
A test function is TypeScript, so it runs on your side, and every change to the places has to be sent over for it to run. Leave the test out where you can.
Tests the game can run
When the test is something the game can decide itself (a place exists, a number passes a mark, a name matches, a value is new), phantom.flash.when takes any condition phantom.flash.rules can build and keeps the whole wait inside Flash, with the places you name read in the frame it held. See Reaching the game client.
const hud = phantom.flash.use('gui.GuiManager').mainHud;
const waited = await phantom.flash.when(phantom.flash.rules.exists(hud), {
read: [hud],
timeoutMs: 30_000,
});
console.log(
waited.met ? `up after ${waited.frames} frames` : 'no HUD in half a minute'
);Cancelling a wait
Both take a signal. When it's aborted they return the same as a timeout: until with null, when with met: false. There's nothing to catch, so the check you already have covers a cancelled run. A supervised automation's task.signal works here.
const giving = new AbortController();
const hud = phantom.flash.use('gui.GuiManager').mainHud;
const waiting = phantom.flash.until(hud, { timeoutMs: 60_000, signal: giving.signal });
giving.abort();
console.log(await waiting);A watched wait is ended in the game, so nothing is left reading there. A wait the game is holding can only be dropped on your side, because the protocol has no unwhen: the game keeps checking until its own timeoutMs runs out, and the answer is thrown away. Give a wait you might cancel a timeoutMs you're happy to pay for.
Waiting in frames
When the client finishes something on a later frame and there's nothing to watch, count frames, not milliseconds. frames is counted in the game, so a slow game is waited for.
phantom.flash.nextFrame() is frames(1).
Try until first. A frame count is still a guess, just a better one.
The same frame
Two writes one after the other are two messages, and the client may run a frame between them. Sent together, they go as one message and run in the same frame.
const view = phantom.flash.use('avatar.AvatarManager').playerAvatarWorldView;
await view.scaleX.$set(2);
await view.scaleY.$set(2);
await Promise.all([view.scaleX.$set(2), view.scaleY.$set(2)]);Reads work the same way. phantom.flash.collect goes further and reads the same members from every item in a list at once.
A value the client keeps putting back
If the client sets something every frame, your write loses. hold keeps the value in the game and writes it every frame. With once and while, it waits for the right moment and writes in that frame, which you can't do reliably from your side.
const { exists, place } = phantom.flash.rules;
const room = phantom.flash.use('room.RoomManagerWorld').instance;
const view = phantom.flash.use('avatar.AvatarManager').playerAvatarWorldView;
await phantom.flash.hold(view.alpha, 0.5, {
while: exists(place(room, '_followPath')),
once: true,
restore: false,
});Waiting for the client to finish starting
A game being playing doesn't mean the player is in Jamaa. The client calls aj_initialized when it starts loading its own files, before the login, the world map and the first room, all of which the player sees as a loading screen.
The client reports those stages, and screens.loading.onProgress passes them on. It names each one (loggingIn, worldMap, room) along with the client's own number, so a tool waiting for startup can say which part it's waiting on.
const stop = phantom.aj.screens.loading.onProgress(({ stage }) => {
if (stage === 'room') console.log('the first room is being joined');
});The same numbers come round again for each later room join, so treat each as a stage, not a percentage of the whole. phantom.aj.screens.loading.onChange tells you whether the loading screen is up, and isShown reads it now.
While the screen is up you can show your own line where the game shows its tip:
await phantom.aj.screens.loading.setShown(true);
await phantom.aj.screens.loading.say('Walking to the Hidden Falls');The client writes its own tip when the screen goes up and removes the screen when it comes down, so call say after the screen is up, and again each time it goes up.
What a value was at the time
A hook's listener hears about a call after it happened, so reading the client then gives you what it holds now. capture reads inside the call and sends the values with it.
await phantom.flash.hook(
'gui.GuiHud.init',
{ phase: 'exit', capture: { self: { frame: 'currentFrameLabel' } } },
(call) => console.log('the HUD was built on frame', call.self['frame'])
);More in Hooks.
Finding the moment
If nothing here covers the moment you need, the client's source will. Find the method that builds the thing, then hook it or pass it to moments. Reading the client says where the source is and how to search it.