Building interfaces in the game
A plugin's panels and pages sit beside the game. phantom.aj.ui draws inside the game instead, on top of the room or in Animal Jam's own screens, with widgets that already look like the game. For anything it has no widget for, use Drawing over the game, the shapes, measuring and dragging it's built on.
It draws in two looks, called skins:
| Skin | What it draws with | When to use it |
|---|---|---|
game | Animal Jam's own library art, its font (gui.Fonts), its palette and its popups | You want it to look like part of the game |
phantom | Phantom's greys: ink #111113, lines #3a3a40, light text #f0f0f2, a near-white accent | You want it to be obviously Phantom's, not the game's |
Phantom's accent is near-white to match the rest of the app, which is monochrome. So the theme has an onAccent: a tick or label on a near-white fill is drawn in the ink colour. For a colour of your own, set one rather than drawing it by hand.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
phantom.aj.ui.skin = 'game';
const window = await phantom.aj.ui.window({
title: 'Room notes',
x: 60,
y: 90,
height: 200,
});
await phantom.aj.ui.label('Who is here', {
parent: window.body,
x: 10,
y: 8,
heading: true,
});
const hello = await phantom.aj.ui.button('Say hello', {
parent: window.body,
x: 10,
y: 32,
});
hello.onClick(() => void phantom.aj.chat.say('hello'));
return () => void phantom.aj.ui.clear();
},
});Setting phantom.aj.ui.skin changes the look for everything you draw after it, except widgets given their own skin option. phantom.aj.ui.theme() gives you a skin's colours, font, text size and corner radius, so your own drawing can match the widgets.
Widgets
Each widget is a function. Call it to remove the widget and anything drawn inside it.
| Widget | What it is |
|---|---|
window(options) | A box with a title bar you drag it by, and a body to draw in |
button(label) | A button. Listen with onClick |
label(text) | Text, plain, as a heading, muted, or on a box |
input(options) | A text field. Keys typed in it never reach the game |
checkbox(label) | A box that's ticked or not |
dropdown(options) | A button that opens a list of choices |
tabs(options) | A strip of tabs, each with its own body |
list(options) | Scrolling rows of rich text, each clickable |
grid(options) | Tiles with a picture, a colour or a few words on each |
image(source) | A picture on its own, from a URL or a data: URL |
scroll(options) | A clipped box with arrows, holding something taller |
tooltip(options) | A small bubble of text, shown where the mouse is |
progress(options) | A bar that fills, with text over it |
colors(options) | The animal colour palette |
And the ones for building a settings screen:
| Widget | What it is |
|---|---|
box(options) | A container that lays its children out as a row, a column or a grid |
card(options) | A title, a line of explanation, a switch, a key chip and a corner button |
toggle(label) | A switch, or a circle or box that fills and shows a tick |
segmented(options) | Two or three choices in one track, one of them pressed |
search(options) | A rounded field with a magnifier in it and a close that empties it |
tabBar(options) | Tabs as words, with a mark under the open one and a count on each |
badge(text) | A small rounded label for a count or a word |
keyChip(keys) | An outlined chip saying which keys work something |
icon(name) | One of Phantom's own icons, drawn as shapes rather than loaded as art |
iconButton(icon) | A button with an icon on it and no words |
In the game skin, a window is the client's own popup: Phantom builds a com.sbi.popup.SBStandardPopup in the client's GUI layer and draws your widgets inside it, so the frame, dragging, close box and darkening are all the client's. Pass darken: true to dim the game behind it like the client's popups do, or modal: true to block the mouse from the client's other popups. window.native says whether you got a native popup. With native: false, or a parent, Phantom draws the same PopupSkin art behind an overlay panel instead.
Either way, the window is laid out by the art's content box (c), where the client puts its own popup text. Height works like SBStandardPopup: the middle slice is as tall as the content, with the wooden top and bottom either side. width is the whole window's width, and in the game skin the art is stretched to it. Leave width out to get the art's own width, widened if the title or minBodyWidth needs it.
The tooltip is the client's own too (gui.ToolTipPopup), and the colour palette is the client's ColorTable, filled from its palettes: the same swatches the avatar editor uses.
Lay out with window.bodyWidth and window.bodyHeight, not the size you asked for, since the frame and title take some of it. Once your widgets are drawn, await window.fit() makes the window as tall as its body's contents plus 8 pixels of padding. The chrome (frame, title row, any header or footer) keeps its height. fit also takes padding, minHeight and maxHeight. window.resize(height) sets the height yourself. Both keep the top left corner in place. In Phantom's skin and the drawn frame, anything in the body past its edge is clipped. A native window doesn't clip, so fit it.
Text is measured in the font it's drawn in, which matters because the client's font is much wider than a plain one at the same size. A button without a width is as wide as its label. Buttons, fields and title rows are as tall as their font needs, and never shorter than the skin's usual row (28 in the game skin, 24 in Phantom's). Labels take wrapWidth. To size something of your own, measure it with phantom.flash.overlay.measure(text, { font: { family: theme.font, size: theme.size } }), where theme comes from await phantom.aj.ui.theme().
const window = await phantom.aj.ui.window({
title: 'Room notes',
x: 60,
y: 90,
height: 260,
});
const names = await phantom.aj.ui.list({
parent: window.body,
x: 4,
y: 4,
width: window.bodyWidth - 8,
height: window.bodyHeight - 8,
});Here's the colour palette in Animal Jam's own widget:
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const palette = await phantom.aj.ui.colors({
skin: 'game',
x: 40,
y: 40,
palette: 1,
});
palette.onPick(
(index) => void phantom.aj.player.avatar.colors.paint({ color1: index })
);
return () => void palette();
},
});onPick gives you a palette index, and colors.paint({ color1: index }) paints it onto one part of the animal. Always name the part. paint also accepts the three packed numbers the game stores, each covering more than one part, so a palette index passed as a packed number resets both body colours to the palette's first colour, and the animal changes once and then never again.
In Phantom's skin the picked swatch gets a ring, like the client's own table.
A tooltip is drawn on top of everything rather than inside a window, so the body's edge doesn't cut it off. Pass the widget it describes as target and it shows and hides with the mouse, and goes when that widget (or its window) does.
const window = await phantom.aj.ui.window({ title: 'Look', x: 60, y: 90 });
const swap = await phantom.aj.ui.button('Other look', {
parent: window.body,
x: 8,
y: 76,
});
await phantom.aj.ui.tooltip({ text: 'Draws the window again', target: swap });Without a target, show a tooltip with showAt and remove it yourself; closing the window it pointed at won't. If the mouse was over the widget at the time (as it is when someone clicks it), the tooltip stays on screen.
Pictures
Three widgets take a picture, as an address the game can load or a base64 data: URL: a grid tile (image), a button (image), and phantom.aj.ui.image on its own. You don't need phantom.flash.overlay.image for this.
import { definePlugin } from '@phantomclient/plugin';
import icon from './map.png?inline';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const map = await phantom.aj.ui.button('World map', {
x: 20,
y: 440,
image: icon,
imageOnly: true,
});
await phantom.aj.ui.tooltip({ text: map.label(), target: map });
map.onClick(() => void phantom.aj.map.open());
return () => void map();
},
});Pictures don't take mouse events, so clicks go straight through to the button or tile underneath.
Without imageOnly you get both: the picture is a square at the leading edge and the words stay in the middle. With it, the picture fills the button and the words aren't drawn. Either way the label is the button's name: label() reads it, setLabel changes it, the tooltip should use it, and it shows instead if the picture never loads, so the button is never blank.
setImage(url) swaps the picture afterwards, and setImage(null) removes it and shows the words. It only works on a button drawn with image; on one drawn without, it throws.
phantom.aj.ui.image draws a picture on its own. With width and height it's drawn at that size, with one of them the other keeps the aspect ratio, and with neither it's drawn at its own size. It holds its place in a layout while loading, so nothing shifts when it arrives. If it never loads it doesn't throw: a box of the intended size is drawn instead, with alt written in it, and onFailed gets the reason.
const badge = await phantom.aj.ui.image('https://example.com/badge.png', {
x: 20,
y: 20,
width: 48,
alt: 'Phantom',
});
badge.onFailed((message) => console.warn(message));Laying widgets out
Every widget takes an x and a y, which is fine for a label beside a button. For anything like a grid of cards, use phantom.aj.ui.box, a container that arranges what's drawn in it, with options like CSS flexbox and grid:
| Option | What it does |
|---|---|
arrange | row (the default), column or grid |
columns | How many tracks a grid divides into |
gap, rowGap, columnGap | The space between children |
padding | The space round the inside: a number, or the sides that differ |
wrap | A row that won't fit goes onto another line |
justify | start, center, end, between, around, evenly along the line |
align | start, center, end, stretch across the line |
width, height | How much room it has. Without them it's as big as its children came to |
A child can carry a span (how many grid columns it covers) and a grow (its share of the room left over along the line).
const mods = await phantom.aj.ui.window({ title: 'Mods', width: 460, height: 320 });
const grid = await phantom.aj.ui.box({
parent: mods.body,
arrange: 'grid',
columns: 2,
gap: 8,
padding: 10,
width: mods.bodyWidth,
});
const card = await phantom.aj.ui.card({
title: 'Auto gems',
description: 'Picks up every gem in the room as you walk past it.',
toggle: true,
width: grid.trackWidth(),
});
await grid.place([{ child: card, width: card.width, height: card.height }]);trackWidth() is the width of one grid column, worked out from the box's rules, so you can read it before any child exists and make each card that wide.
A child whose size you pass isn't measured, so a grid of cards made at the track width needs no calls to the game, and works on a loader without measuring. Anything else is measured where it is and placed by its visible content rather than its position, so a centred label lands on the line. again() lays the same children out again, and rearrange(changes) changes the rules and does the same.
Widgets keep their own x and y; a box sets them for you.
Cards, switches and the rest
A list row is text and a grid tile is a picture. For a settings row, with text and a switch, use a card:
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const card = await phantom.aj.ui.card({
x: 10,
y: 10,
width: 210,
title: 'Auto gems',
description: 'Picks up every gem in the room as you walk past it.',
toggle: true,
keys: 'Ctrl+Shift+G',
action: 'globe',
});
card.onToggle((on) => console.log(on ? 'on' : 'off'));
card.onAction(
() => void phantom.aj.ui.popup.message('This one runs for every account.')
);
return () => void card();
},
});Turning a card's toggle on gives the card a tinted background and a line down its leading edge, so onToggle is the only listener a mod menu needs. onAction fires for the small corner button, and onClick for a click anywhere else on the card.
A card is sized for lines of description (two by default) rather than measured, so every card in a grid is the same height. Its width and height give the final size.
toggle is the switch on its own. shape is switch (a sliding pill), round or box; the last two fill with the accent and show a tick, as on a card. checkbox is the plain tick box from the client's own settings, for when you want to look like the game.
segmented is a choice between two or three short options, and search is a rounded field with a magnifier and a clear button that appears once there's text. badge and keyChip are small labels: filled for a count, outlined for keys. Both give their width, so you can place things beside them without measuring.
tabBar is tabs as words, with a mark under the open one and a count beside each. It only draws the strip; swapping what each tab shows is up to you. tabs gives each tab a body and shows and hides it for you.
Icons
UI_SYMBOLS is Animal Jam's art, so it only exists in the game skin and in the shapes Animal Jam drew. Phantom's icons are drawn as lines and circles on a 16 by 16 grid and scaled, so they're sharp at 10 pixels or 24, take the theme's colour, need nothing loaded, and work in either skin.
const theme = await phantom.aj.ui.theme();
const mark = await phantom.aj.ui.icon('globe', {
x: 20,
y: 20,
size: 18,
color: theme.muted,
});
await mark.setIcon('check');phantom.aj.ui.icons() lists them: magnifier, globe, close, check, the four chevrons, ring, dot, plus, minus, bars, filter, gear, square, arrowUp, arrowDown, warning, info, zoomIn, keyboard, camera, sparkle, focus, eye, eyeOff, clock, chat, tag, gauge and shirt. Icons let the mouse through to whatever they're drawn over, which is how iconButton works: plain leaves off the box behind it, round makes it fully rounded, and primary draws it in the accent.
Changing what a skin draws with
phantom.aj.ui.restyle changes part of a skin for everything you draw after it, so you can use your plugin's own colours without drawing every widget yourself with phantom.flash.overlay.
phantom.aj.ui.restyle({ accent: 0x22c55e, cornerRadius: 10, size: 13 });
const window = await phantom.aj.ui.window({
title: 'Mine',
subtitle: 'In my own colours',
});
phantom.aj.ui.restyle(null);You can name any of ink, surface, raised, selected, line, light, muted, accent, onAccent, danger, font, size and cornerRadius. Anything else throws on that line, rather than reaching Flash as NaN and coming out black.
| Colour | What it's for |
|---|---|
ink | The window's own background |
surface | A card, a field or a pill standing on it: one step off the background |
raised | A header or footer band, and what a row goes to under the mouse |
selected | The tint behind something that's on |
accent | The chosen tab, the filled switch, the mark down a selected card |
onAccent | Words and marks drawn on the accent |
The change only applies to that skin, and phantom.aj.ui.theme() returns the changed theme so your own drawing still matches. It's only on this screen: nothing is saved and nobody else sees it.
Subtitle, header and footer
A window can also have a subtitle, a header and a footer:
const window = await phantom.aj.ui.window({
title: 'Mod Menu',
subtitle: '8 of 34 mods enabled',
header: true,
footer: true,
closable: false,
width: 460,
height: 340,
});
const close = await phantom.aj.ui.iconButton('close', {
parent: window.header,
plain: true,
});The title stays in the panel's bar, which is what you drag the window by. The subtitle is the first line of the body, and the header and footer are bands below and above it, each with a divider. body is what's left in the middle, and bodyHeight already excludes them.
A window with a header usually wants closable: false and its own close button in the header, since the corner one would otherwise sit on the subtitle.
A scroll box takes a scrollbar option: thin is a rounded bar that shows the scroll position, arrows is two buttons, and none leaves only the mouse wheel. Use thin in Phantom's skin and arrows in the game's, where a thin bar wouldn't look like Animal Jam.
Example: a mod menu
All of the above in one window, with no phantom.flash.overlay:
import { definePlugin } from '@phantomclient/plugin';
type Mod = { id: string; title: string; about: string; keys?: string; on: boolean };
const MODS: Mod[] = [
{
id: 'gems',
title: 'Auto gems',
about: 'Picks up every gem in the room as you walk past it.',
keys: 'Ctrl+Shift+G',
on: true,
},
{
id: 'travel',
title: 'Fast travel',
about: 'Walks you to a doorway and through it in one go.',
on: false,
},
{
id: 'clean',
title: 'Clean room',
about: 'Hides the other players, their pets and their chat.',
keys: 'Ctrl+Shift+X',
on: true,
},
{
id: 'names',
title: 'Name tags',
about: 'Puts a name above every player, members and all.',
on: false,
},
];
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const window = await phantom.aj.ui.window({
title: 'Mod Menu',
subtitle: `${MODS.filter((mod) => mod.on).length} of 34 mods enabled`,
header: true,
footer: true,
closable: false,
draggable: true,
x: 60,
y: 60,
width: 470,
height: 360,
});
const search = await phantom.aj.ui.search({
width: 170,
placeholder: 'Search mods...',
});
const onlyOn = await phantom.aj.ui.toggle('Enabled only', { shape: 'switch' });
const close = await phantom.aj.ui.iconButton('close', { plain: true });
const header = await phantom.aj.ui.box({
parent: window.header,
arrange: 'row',
gap: 8,
padding: { top: 5, right: 10, left: 10 },
width: window.bodyWidth,
justify: 'end',
align: 'center',
});
await header.place([search, onlyOn, close]);
close.onClick(() => void window());
const tabs = await phantom.aj.ui.tabBar({
parent: window.body,
x: 10,
y: 2,
width: window.bodyWidth - 20,
tabs: [
{ value: 'mods', label: 'Mods', badge: 12 },
{ value: 'enhancements', label: 'Enhancements', badge: 23 },
{ value: 'popups', label: 'Popups', badge: 10 },
],
});
const scroll = await phantom.aj.ui.scroll({
parent: window.body,
x: 6,
y: tabs.height + 6,
width: window.bodyWidth - 12,
height: window.bodyHeight - tabs.height - 12,
scrollbar: 'thin',
});
const grid = await phantom.aj.ui.box({
parent: scroll.body,
arrange: 'grid',
columns: 2,
gap: 8,
padding: 8,
width: window.bodyWidth - 24,
});
const enabled = () => `${MODS.filter((mod) => mod.on).length} of 34 mods enabled`;
const draw = async (mods: Mod[]) => {
const cards = await Promise.all(
mods.map(async (mod) => {
const card = await phantom.aj.ui.card({
title: mod.title,
description: mod.about,
toggle: true,
checked: mod.on,
action: 'globe',
width: grid.trackWidth(),
...(mod.keys === undefined ? {} : { keys: mod.keys }),
});
card.onToggle((on) => {
mod.on = on;
void window.setSubtitle(enabled());
});
card.onAction(
() => void phantom.aj.ui.popup.message(`${mod.title} runs everywhere.`)
);
return card;
})
);
const laid = await grid.place(
cards.map((card) => ({ child: card, width: card.width, height: card.height }))
);
await scroll.setContentHeight(laid.height);
return cards;
};
let cards = await draw(MODS);
const redraw = async () => {
for (const card of cards.splice(0)) await card();
const text = search.value().toLowerCase();
cards = await draw(
MODS.filter((mod) => (onlyOn.checked() ? mod.on : true)).filter((mod) =>
mod.title.toLowerCase().includes(text)
)
);
};
search.onChange(() => void redraw());
onlyOn.onChange(() => void redraw());
tabs.onSelect(() => void redraw());
const scope = await phantom.aj.ui.segmented({
width: 210,
choices: [
{ value: 'all', label: 'All accounts' },
{ value: 'one', label: 'This account only' },
],
});
const hints = await Promise.all(
['F10', 'Esc', 'Ctrl+F'].map((keys) => phantom.aj.ui.keyChip(keys))
);
const footer = await phantom.aj.ui.box({
parent: window.footer,
arrange: 'row',
gap: 6,
padding: { top: 6, right: 10, left: 10 },
width: window.bodyWidth,
align: 'center',
});
await footer.place([
{ child: scope, width: scope.width, height: scope.height },
{ child: hints[0]!, width: hints[0]!.width, height: hints[0]!.height, grow: 1 },
...hints
.slice(1)
.map((chip) => ({ child: chip, width: chip.width, height: chip.height })),
]);
return () => void phantom.aj.ui.clear();
},
});grid.trackWidth() is read before any card exists, so each card is made at its column's width. Passing each card's width and height to the box means it never measures, so a grid of twelve cards is one message to the game rather than thirteen.
Waiting for the game to load
The game skin uses things the client loads as the player logs in: its library art (the popup frame, tooltip, popups and symbols) and its colour palettes. Your plugin can start before that, so each game skin widget waits for what it needs instead of drawing a stand-in.
windowwaits for the popup frame. If the art hasn't arrived withinphantom.aj.ui.readyTimeoutMs(two minutes by default, which is long enough to log in), the window is drawn in the game skin's colours instead,window.framedisfalse, and a warning says why.symbol, the game skin'stooltipand allpopupcalls wait for the art the same way. After the timeout, a symbol rejects, a popup settles as though it had been closed, and a tooltip falls back to the overlay's own bubble.colorsandpaletteValueswait for the palettes, in either skin. After the timeout they reject with "The game has not loaded its colour palettes yet".
phantom.aj.ui.status() tells you what the client has loaded so far. phantom.aj.ui.ready() waits for both, and rejects with whatever's missing after the timeout. You don't need either to draw, but they're useful for picking a skin or showing a message while you wait.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const { art } = await phantom.aj.ui.status();
if (!art) ctx.log('Waiting for the game to load before drawing');
await phantom.aj.ui.ready();
const window = await phantom.aj.ui.window({
skin: 'game',
title: 'Ready',
height: 200,
});
return () => void window();
},
});Phantom's skin doesn't need anything from the client, so it draws everything except the palette straight away.
Animal Jam's own art
phantom.aj.ui.symbol(name) puts one of the client's library symbols on screen: the real art, created the same way the client does it (GETDEFINITIONBYNAME). This covers all of Animal Jam's art, not just what the widgets use.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
if (!(await phantom.aj.ui.hasSymbol('AnimsTitleBanner'))) return;
const banner = await phantom.aj.ui.symbol('AnimsTitleBanner', { x: 450, y: 60 });
await banner.on('click', () => console.log('clicked the banner'));
return () => void banner();
},
});phantom.aj.ui.symbols() lists the useful ones with a line about each. That includes the frames the popups are built from (PopupSkin, OkPopupSkin, YesNoPopupSkin), the tooltip, the scrollbar, the name bars, and whole screens like MySettings and DenEditorAsset. Any other name in the HUD assets works too. Use hasSymbol to check one exists.
The parts inside a symbol have names, which you can reach with symbol.object:
const frame = await phantom.aj.ui.symbol('OkPopupContent', { x: 450, y: 275 });
await frame.object.at('okBtn', 'visible').set(false);Asking the player something
phantom.aj.ui.prompt(question, options) asks for a line of text and resolves with it. You get null if the player cancels, presses Escape or closes the window. choose(question, choices) asks for one of a few choices, and menu(items) opens a menu at the mouse. The question wraps to the window, the field (or list) goes under it, then buttons sized to their labels, and the window is fitted to all of it. Pass a width to fix the width: the question wraps to it, and buttons stack if they don't fit side by side.
const note = await phantom.aj.ui.prompt('What shall I say?', {
skin: 'game',
title: 'Room notes',
});
if (note !== null) await phantom.aj.chat.say(note);In the game skin the prompt is a native window, and the text box is the client's own flash.text.TextField in its embedded font. Enter answers and Escape cancels. (The client's own typing popup, gui.InputPopup, only accepts its pre-approved predictive-text phrases, 21 characters at most, so it can't be used for free text.) The arrow keys still move your animal while you type, as in the client's own text fields. Phantom's skin uses the overlay's field, which never passes keys to the game.
When a layout looks wrong
phantom.aj.ui.debugLayout(widget) measures everything drawn in a widget and reports where each thing landed and whether it's inside the box it should be in (for a window, the body). It logs a table and resolves with the report. Run it in the Developer console and include the output with your bug report:
const notes = await phantom.aj.ui.window({ skin: 'game', title: 'Room notes' });
await phantom.aj.ui.label('Who is here', { parent: notes.body, x: 8, y: 8 });
await notes.fit();
const report = await phantom.aj.ui.debugLayout(notes);
console.log(JSON.stringify(report));report.ok is false if anything sticks out. Each row's overflow says how far it goes past each edge. report.window gives the window's sizes, and whether it's native and framed. phantom.flash.overlay.inspect(node) gives the raw tree under any overlay node.
The game's popups
phantom.aj.ui.popup opens Animal Jam's real dialogs in the client's own layer. Each returns a promise that resolves with what the player chose, and has close(), which shuts the popup and settles the promise. Call it when your plugin stops.
| Call | What the player sees | What it resolves with |
|---|---|---|
popup.message() | A line of text and a close box | nothing, when it closes |
popup.ok() | A line of text and OK | nothing, when it closes |
popup.okCancel() | OK and Cancel | true, false or null |
popup.yesNo() | Yes and No | true, false or null |
popup.titled() | A title bar, text and a close box | nothing, when it closes |
popup.parchment() | The scroll the game asks on | true or false |
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
commands: {
leave: async (_input, phantom) => {
const sure = await phantom.aj.ui.popup.yesNo('Leave this room?');
if (sure !== true) return { message: 'Staying put' };
await phantom.aj.room.join('jamaa_township.room_main#1');
return { message: 'Off to Jamaa Township', tone: 'success' };
},
},
});null means the player closed the popup without answering.
Adding to the client's screens
The widgets above are drawn over the game. To put something inside it, name the container.
ui.attach(place, widget)draws the widget inside one of the client's containers. It moves, scales and hides with the container, and stacks with its children instead of sitting above everything.ui.replace(place, widget)hides one of the client's display objects and draws your widget in its place, in the same parent and position. The client's comes back when your widget goes.ui.hide(place)hides one of the client's pieces until you undo it, even if the client tries to show it again.
A place is a named spot: stage, gui (the layer windows and popups go in), hud, room, avatarEditor, denEditor, or part of the HUD like chatBar, money or buddyListBtn. ui.places() lists them. ui.place(spot) turns one into a place you can pass to anything else that takes one, like an overlay anchor.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const notes = await phantom.aj.ui.button('Notes', {
skin: 'game',
width: 60,
height: 20,
});
const remove = await phantom.aj.ui.attach('hud', notes);
notes.onClick(() => void phantom.aj.ui.popup.ok('Nothing written yet.'));
return () => void remove();
},
});You can name a screen that isn't open yet. A place is a path Phantom looks up every few frames, and the widget stays hidden until the screen appears.
When a screen opens
ui.onScreen(name, listener) hooks the screen's own init and destroy. The listener runs once the screen has finished building, so there's something to add to.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
setup(ctx) {
const phantom = ctx.phantom();
return phantom.aj.ui.onScreen('avatarEditor', async ({ open, spot }) => {
if (!open || !spot) return;
const random = await phantom.aj.ui.button('Randomise', {
skin: 'game',
width: 80,
height: 22,
});
await phantom.aj.ui.attach(spot, random);
});
},
});ui.screens() lists the screens: avatarEditor, denEditor, settings, buddyList, petCreator, achievements, bulletinBoard, playerSearch, recycle, trade, petInventory, reportPlayer and serverSelector. The ones with their own display object come with its spot. For the others, use phantom.aj.gui.find to find something to add to.
Using the client's widgets
ui.attach and ui.replace put Phantom's widgets next to the client's. To work with one of the client's own, name it with ui.place:
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const grid = phantom.aj.ui.place('avatarEditor', 'colorTableColor1');
const columns = await grid.$at('_numCols').$get();
const rows = await grid.$at('_numRows').$get();
console.log('the colour grid is', columns, 'by', rows);
const shuffle = await phantom.aj.ui.button('Shuffle', {
skin: 'game',
width: 80,
height: 22,
});
const remove = await phantom.aj.ui.attach('avatarEditor', shuffle);
return () => void remove();
},
});A place is a typed place in the client, like phantom.flash gives you: $get, $set, $call, $listen, $at and the rest. It doesn't touch the client until you use one of them, so naming a screen that isn't open is fine. You can reach any member through it by name, private or not. $call runs the client's own method, so anything that method sends to Animal Jam is still sent.
Adding to a list or grid the client builds
The client fills its lists, grids and colour tables by calling one of its methods with the data as an argument. A hook with replaceArgs swaps in your arguments. What's drawn is still the client's widget, with its callbacks attached.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const { flash } = ctx.phantom();
const stop = await flash.hook('gui.ColorTable.init', {
replaceArgs: { colorIndexes: [10, 11, 12, 13, 14, 15], numCols: 3, numRows: 2 },
});
return () => void stop();
},
});The arguments are replaced inside the call, in the game, so the client doesn't wait. To decide per call, use flash.override and call call.original(...) with your own arguments. The client's method then runs only once your handler answers, which can empty a widget the client is building at the same time. Reaching the client explains why.
Changing which colours the avatar editor offers has its own call, because other players can only see colours the game already knows. See Watching and changing it.
Contributing a row to one of its lists
Hooking the method that fills a list means supplying all of its data. With ui.contribute you declare one row of your own instead, and Phantom adds it every time the client builds the list.
These lists are rebuilt from the client's data often, not only when their screen opens: the avatar editor's grid on every tab (AvatarEditor.createItemWindows), the buddy list whenever a buddy comes online (BuddyList.onBuddyListChange), the world picker when the server sends the shard list (ServerSelector.createServerButtons). So a contribution is a declaration, not an insert: Phantom holds it, hooks the client's method that fills the list, and adds the row back each time, including after the screen is reopened and after the page reloads on a server switch. Your plugin doesn't need to do anything.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
let on = false;
const row = await phantom.aj.ui.contribute(
'settings',
{
name: 'nameTags',
label: 'Name tags',
tooltip: 'Draw a name over every animal in the room.',
control: { kind: 'toggle', on, icon: 'denPlayerIcon' },
},
(press) => {
on = press.on === true;
console.log('name tags are now', on);
}
);
return () => void row();
},
});What's drawn is a real Animal Jam row, built from the client's own item class for that list (gui.itemWindows.ItemWindowToggle for a Settings row, ItemWindowOriginal for a grid tile), with the client's art, switch, hover and press. The label is written into the client's own text field afterwards, as the client does with its own rows.
The lists you can contribute to
Each screen's lists are named after it. For a screen with one list you don't need list on the row.
| List | Screen | What one row is | Controls |
|---|---|---|---|
settings.rows | settings | a Settings row (ItemWindowToggle) | toggle, button, label |
serverSelector.worlds | serverSelector | a world bar in the world picker (ItemWindowServer) | label |
buddyList.buddies | buddyList | a line of the buddy list (ItemWindowBuddyList) | label |
avatarEditor.items | avatarEditor | a tile of the clothes and den grid (ItemWindowOriginal) | tile |
denEditor.items | denEditor | a tile of the den editor's strip (ItemWindowOriginal) | tile |
A row takes one of four controls. The first three are row types from gui/MySettings.as; the fourth is an item grid tile.
| Control | What the player gets |
|---|---|
{ kind: 'toggle', on } | Animal Jam's own On/Off switch |
{ kind: 'button', button: 'Open' } | one of its labelled buttons |
{ kind: 'label' } | a row of words and a picture, with nothing to press |
{ kind: 'tile' } | one square of an item grid, with a picture on it |
ui.explore(screen) reports which of these each list draws.
A tile has no text field, so its label is the name the client knows it by (ItemWindowOriginal.itemName, which the editor's search reads), and tooltip is what the player sees on hover. A tile with no icon is the client's empty square: pressable, with your tooltip, and nothing drawn in it.
A picture of your own
icon is one of Animal Jam's pictures by name, one of yours by address, or one of Phantom's icons ({ draw }).
await phantom.aj.ui.contribute('settings', {
name: 'nameTags',
label: 'Name tags',
control: { kind: 'toggle', icon: 'denPlayerIcon' },
});
await phantom.aj.ui.contribute('avatarEditor', {
name: 'wardrobe',
label: 'Saved looks',
tooltip: 'Open the wardrobe',
control: {
kind: 'tile',
icon: { image: '/my-plugin/wardrobe.png', alt: 'Wardrobe' },
},
});
await phantom.aj.ui.contribute('settings', {
name: 'wasd',
label: 'Walk with WASD',
control: { kind: 'toggle', icon: { draw: 'keyboard' } },
});The stock names are the ones the client's art has frames for. autoServer, buddyRequest, denPlayerIcon, doorBell, gameInvites, jammerWallIcon, lock, music and predictTxt have On and Off states, so they suit a switch. intro, nameBadge, redeemCode, twoFactor, verifyEmail and world are a single picture, for a button or label row.
{ image } is an address the game can load or a data: URL, like a widget's picture. It's fitted inside the client's icon box with the stock picture hidden, so the rest of the row is still the client's. That means your picture doesn't animate with a switch (the client's animation is frames in its own clip), and a list whose rows have no icon box refuses it. Grid tiles have no stock pictures, since the client draws the item's art there, so a tile's picture is always yours. list.picture in ui.explore says which lists take one.
{ draw } names one of the icons from phantom.aj.ui.icons(), drawn the same way: in the client's icon box, slightly inset, with the stock picture hidden. It's drawn in the game skin's ink unless color sets another (0xRRGGBB). An unknown icon name or an invalid colour is refused when the row is declared.
row.set({ on: true }) moves the switch without declaring the row again, and the change is kept for the next time the screen is built. set also takes a new icon. row.row() is the row itself while the screen is open, and null while it's closed. row() removes it from the open screen and from future ones.
Lists you can't contribute to
These lists were checked and left out:
| Screen | What stands in the way |
|---|---|
bulletinBoard, playerSearch, reportPlayer | No generated list at all. Each draws straight into its own popup art, so there is nothing a row could be one of. |
petInventory | ItemWindowPets.loadCurrItem reads PetManager.getPetDef(_currItem.defId).isMember and builds a GuiPet from the item, so a row that is not a real pet is a null reference inside the client. |
petCreator | ItemWindowAvatarOrPetSelect's constructor coerces the item with param2 as PetItem and reads isDiamond off the result, which is null for anything that is not one. |
achievements | Nested. AchievementViewer.setupContent builds one generator per section and feeds the outer list the generators themselves as its items, so a contributed row would have to be a whole section rather than a line. |
recycle | No one place to read. RecycleItems is held by whichever screen opened it (AvatarEditor._recycle, DenEditor._recycle, RoomManagerWorld._recycle, Shop._recyclePopup, and three more), so the list that is up cannot be found. |
trade | TradeManager.buildItemWindows fills _itemWindows and _itemWindowTheirs for four different popups and both sides of a trade from one method, and the trade reads those windows by position (mediaWindows[0]). An extra tile would shift what the client thinks is being offered. |
| The avatar editor's name bar | ItemWindowNameBarIcon.loadIcon loads the item as a media id through MediaHelper, so the picture can only be one Animal Jam has uploaded. |
recycle and trade use the same ItemWindowOriginal tile as the two editors, and could be supported if the client kept its popup in one place. If you need one of them, ask on the issue tracker rather than reaching into Flash.
Finding out what a screen is made of
ui.explore(screen) tells you how a screen's lists are built (for example, that a settings row is a WindowGenerator inside _mySettings.itemWindow built from an array of plain objects), in the terms a contribution uses, without decompiling the client.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const report = await phantom.aj.ui.explore('settings');
console.log('open:', report.open, 'children:', report.children.join(', '));
for (const list of report.lists) {
console.log(list.name, 'draws', list.itemClass, 'with', list.rows, 'rows');
console.log('a row carries', list.fields.join(', '));
console.log('and may have', list.controls.join(', '));
if (list.picture) console.log('and can show a picture of your own');
}
},
});phantom.aj.ui.inspect reads the whole display tree under a screen in one go (names, classes, frames, what a timeline placed, where each object is), phantom.aj.gui.tree walks the same display objects, and phantom.flash.describe lists every member of one.
A tab of your own on the buddy card
phantom.aj.buddies.card.addTab adds a side tab like the game's own to the buddy card: a button on the card's left edge between its Buddy Games and Trade List buttons (more tabs go in a column below them) that slides out a panel, as theirs do, with your title and content.
declare function loadNote(userName: string): Promise<string | null>;
const notes = await phantom.aj.buddies.card.addTab({
id: 'notes',
title: 'Notes',
icon: { draw: 'info' },
content: async (card) =>
(await loadNote(card.userName)) ?? { text: 'No notes yet', muted: true },
onError: (error) => console.warn('the notes tab:', error),
});
await notes.show('Saved!', { for: 'localhost' });
await notes();Each call adds one tab. Tabs stack down the card's left edge by order (lowest first; Phantom's own Bio tab is first), each with its own name on the card.
It's a real game tab. Phantom's loader builds each tab from the card's own classes as the card opens. Its button is a new instance of the Buddy Games tab's button class, so its hover, press, greyed state and sounds are the game's own, and your picture replaces the games picture on each state (read from the state's vector data, keeping the face). Next to it go a copy of that tab's panel and title, with your title in the game's font. The card's file isn't changed and no code is generated. The SDK wires it up: the button slides the panel with the game's own tween and sound, any open game tab closes as yours opens (and yours closes when one of theirs is pressed), and your text goes in its own field, wrapped, in the game's font.
import type { GraphicsCall } from '@phantomclient/plugin/sdk';
declare function favouriteOf(userName: string): string | undefined;
const OUTLINE = 0x66321b;
const star: GraphicsCall[] = [
{ op: 'lineStyle', args: [5, OUTLINE, 1] },
{ op: 'beginFill', args: [0xffba1f, 1] },
{ op: 'moveTo', args: [50, 8] },
{ op: 'lineTo', args: [62, 38] },
{ op: 'lineTo', args: [94, 40] },
{ op: 'lineTo', args: [69, 60] },
{ op: 'lineTo', args: [78, 92] },
{ op: 'lineTo', args: [50, 74] },
{ op: 'lineTo', args: [22, 92] },
{ op: 'lineTo', args: [31, 60] },
{ op: 'lineTo', args: [6, 40] },
{ op: 'lineTo', args: [38, 38] },
{ op: 'lineTo', args: [50, 8] },
{ op: 'endFill', args: [] },
];
const favourites = await phantom.aj.buddies.card.addTab({
id: 'favourites',
title: 'Favourites',
icon: { calls: star, size: 100 },
order: 10,
content: (card) => favouriteOf(card.userName) ?? { text: 'Nothing yet', muted: true },
onBuilt: ({ look }) => console.log('favourites tab:', look),
});If the loader can't build it (an older loader, or a card that isn't as expected, reported once to onError), the tab is made at run time from a copy of the Buddy Games tab, with its look laid over that tab's button (drawn). It still slides and fills the same way.
The icon is one of ui.icons() ({ draw: 'gear', color }), your own vector drawing ({ calls, size }, Graphics calls in a box size units square), a PNG's bytes ({ png }, laid on the button at run time), or a display object, such as a PNG loaded with phantom.aj.content.load ({ display: loaded.content }, laid on the button at run time). Content is text (plain, in the game's font; muted greys it) or { display }. Errors while a card opens go to onError (console.error by default), since nothing is awaiting it then.
Two plugins on the same screen
Contributed rows go under the client's own, in the order they were declared. Each is added separately, in its own try, so a row that fails to build doesn't break the others. A row whose name is already contributed to the same list is refused when declared.
ui.contributions() lists your plugin's contributions and how each has done: how many times it was added to a freshly built list, how many of those failed, and the last error. A plugin can't see or remove another plugin's rows.
Some of these methods reuse the old generator rather than making a new one, so Phantom checks whether its row is still one of the list's windows and only rebuilds it if not. That's why applied can be lower than the number of times a screen was opened.
When your plugin stops, its rows are removed with everything else it drew.
Cleaning up
Each widget is also the function that removes it. Removing a window removes everything in it, including the client's pieces: the frame and close button of a game window, a ColorTable, a symbol, and the handles and listeners each one held.
If a widget is still being drawn when its window goes (a palette waiting for the game's colours, for example), it rejects rather than appearing at the top of the screen.
attach, replace, hide and feed each return a function that undoes them.
ui.clear() removes everything your plugin drew in one go, popups included. If your plugin has two windows, both go, so prefer closing specific things when you can. Everything is removed when the game closes anyway; use clear to stop cleanly or start over.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
await phantom.aj.ui.label('Phantom is watching', {
x: 20,
y: 20,
background: true,
});
return () => void phantom.aj.ui.clear();
},
});Permissions
{
"name": "phantom-plugin-room-notes",
"version": "1.0.0",
"phantom": {
"id": "acme.room-notes",
"title": "Room notes",
"description": "A window in the game for notes about the room you are in.",
"author": "Acme",
"permissions": { "game": "act" }
}
}"game": "read" lets a plugin see which of the game's screens and containers it can draw in, what they're made of and when they open, and draw on the overlay. Building interfaces from the game's own pieces creates and changes client objects, which needs "game": "act". Drawing goes through the plugin's own phantom.flash in its frame. See Permissions.
Nothing Phantom draws is saved on the player's computer; it's all in the running game and goes when the game closes. What your plugin makes the client do is different: a place's $call or a hook's replaceArgs can make the client send things to Animal Jam, which are still sent. Reaching the client covers that.