Real-time channels
A real-time channel lets copies of your plugin on different computers send each other messages live: a counter everyone in the room taps, a game of tic-tac-toe between two players, a lobby that fills up before a round. Messages go through Phantom's server over the connection the Phantom community already uses, so you don't need your own server.
Only copies of your plugin are in a channel. Other plugins can't hear it, and nothing reaches Animal Jam.
Quick example
import { definePlugin } from '@phantomclient/plugin';
type Tap = { taps: number };
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const room = await phantom.aj.realtime.join<Tap>({ scope: 'room' });
let taps = 0;
const stops = [
room.onMessage(({ from, data }) => {
taps = Math.max(taps, data.taps);
ctx.log(`${from.screenName} tapped, ${taps} so far`);
}),
room.onJoin((member) => ctx.log(`${member.screenName} is here`)),
room.onLeave((member) => ctx.log(`${member.screenName} left`)),
room.onClose(({ reason, message }) => ctx.log(`Closed (${reason}): ${message}`)),
];
taps += 1;
await room.send({ taps });
return () => {
stops.forEach((stop) => stop());
void room.leave();
};
},
});Joining a channel shows the player to other people, so the manifest asks for realtime. It's reached through ctx.phantom(), so it needs game too.
"permissions": { "game": "read", "realtime": true }join returns a channel object:
send(data)sends to everyone else in the channel.sendTo(member.id, data)sends to one member.ask(member.id, question)asks one member and waits for their answer.onAsk(handler)answers what the others ask.members()returns who's there now.state()andsetState(value)are the channel's one shared value.presence()andsetPresence(value)are what the others see about this player.isHostandhost()are the one member Phantom names as the channel's referee.leave()takes the player out.
The channel keeps track of members as people come and go. The type you pass to join is used for both sending and receiving, so both ends agree on the message shape.
Channel types
| Kind | Who's in it |
|---|---|
{ scope: 'room' } | Everyone running your plugin in the player's room. |
{ scope: 'plugin' } | Everyone online running your plugin, in groups of 200. |
{ scope: 'named', name: '…' } | Everyone who joined the same name from your plugin, 200 of them. The 201st is refused. |
{ scope: 'named', name: '…', split: true } | The same, but past 200 it takes another part beside the first instead of refusing. |
Room channels
A room channel follows the player. When they move to another room, people in the old room get onLeave and people in the new room get onJoin, and the player sees everyone in the old room leave and everyone in the new room arrive. This happens once per room change, after the player has settled in the new room, without rejoining the channel. Shared custom rooms work the same way, with everyone in the same instance in the channel. Between rooms the channel is empty.
A custom room the player entered alone works like any other room, with nobody else in it: the channel has one member, the player is its host, and the state is theirs while they're there. Two players in their own copies of the same room don't meet, and no plugin can reach someone else's by naming the room. When the player walks out, the channel follows them back to the Animal Jam room they came from.
The host belongs to the room too. Walk into an empty room and the player is its host; walk out and the room picks someone else. Between rooms there's no host.
Plugin channels
A plugin channel covers everyone online, split into groups of 200 so one message never goes out to thousands of people. Joining puts the player in the fullest group with space, and they talk to the people in that group. channel.part says which group. To choose the groups yourself, use named channels.
Named channels
You choose the name: letters, digits and . _ : -, up to 64 characters, starting with a letter or digit (lobby, table:12, race-4). A named channel holds 200. Once it's full, join rejects with a message saying so, and you can try another name.
It refuses by default because you picked the name. If lobby quietly became three lobbies that couldn't hear each other, you'd have no way to tell, and you'd never see it while testing with five people. So splitting is opt-in:
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const lobby = await phantom.aj.realtime.join({
scope: 'named',
name: 'lobby',
split: true,
});
ctx.log(`In the lobby, part ${lobby.part}, with ${lobby.members().length} others`);
return () => void lobby.leave();
},
});With split: true, the 201st player to join starts a second part, the 401st a third, and so on. channel.part is the part the player landed in, counting from 1. Parts don't hear each other: send reaches the player's part, members() is their part, and someone in part 2 never sees anyone in part
- Show
partin your UI if it matters to players, or use it to label a lobby (Lobby 2).
Joins fill the fullest part with space, so parts stay full rather than spreading thin. Each part has its own host and its own state. Players can't choose their part. To keep people together, give them a name to agree on (table:12) and leave split off, so a full table is a refusal you can handle rather than a split you can't see.
A plugin joining lobby without split and one joining it with split share the first part until it fills. Only the split join goes on to a second part.
Members
Each member has a screen name, an id, and whatever your plugin has set about them.
type RealtimeMember = { id: string; screenName: string; presence?: object };The screen name is the player's Animal Jam name, checked against the players linked to their Phantom account, so nobody can pretend to be someone else.
The id is specific to your plugin. It stays the same each time that player comes back to your plugin, on any computer, so you can use it for a high score table or to recognise a returning opponent. Another plugin sees a different id for the same person, and each Animal Jam account linked to the same Phantom account gets its own id. It isn't their Phantom account, username, email or Animal Jam id, and can't be turned back into any of those.
channel.you is the player as everyone else sees them.
Keeping a member list
channel.members() is who's there right now, and a panel showing them has to redraw when that changes. That takes three listeners: onJoin, onLeave and onPresence. onMembers combines all three with the current list.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const room = await phantom.aj.realtime.join({ scope: 'room' });
return room.onMembers((members) => {
ctx.log(
`${members.length} here: ${members.map((each) => each.screenName).join(', ')}`
);
});
},
});It calls your listener once before returning, with everyone there now, and again on every arrival, departure and presence change. Presence changes are the ones people forget when writing this by hand, which leaves colours and scores in the list out of date.
Channel state
A channel holds one small object everyone in it shares. You get it when you join, so a plugin that keeps a score, a board or a running total has it straight away.
import { definePlugin } from '@phantomclient/plugin';
type Board = { turn: 'x' | 'o'; cells: string[] };
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const game = await phantom.aj.realtime.join<unknown, Board>({
scope: 'named',
name: 'table:12',
});
let board = game.state() ?? { turn: 'x', cells: Array(9).fill('') };
const stop = game.onState(({ state }) => {
if (state) board = state;
ctx.log(`${board.turn} to play`);
});
await game.setState({ ...board, turn: 'o' });
return stop;
},
});setState replaces the whole object; there's no merging. Any member can write it, and the last write Phantom's server receives wins, so two plugins writing at once both succeed and the second one stands. If your plugin needs a single writer, pick one from the member ids everyone can see, like the lowest id or whoever joined first.
onState fires for your own writes as well as everyone else's, once the server has put them in order. Draw from there rather than from what you passed to setState, and your copy will match everyone else's. from is the id of the member who set it, or null when the player was just given the existing state.
null clears the state. State only exists while someone is in the channel: when the last member leaves, it's gone, and the next person to join finds nothing. Nothing is written to a database.
What the state isn't for
It's one 4 KB object, replaced whole. Thirty things each with a position and a holder don't fit: every change rewrites all of it, and two changes at the same moment means one is lost.
For things in a custom room that people take from each other, use objects instead. Phantom's server holds a small record for each, per shared room instance, and decides take once, so the loser gets a refusal rather than a second sword. You don't need a referee or your own rules, and every plugin gets the same rules.
Channel state is for your plugin's own data: a score, a board, whose turn it is.
Publishing frequent changes
A board, score or running total can change far more often than the four times a second Phantom sends state. publish is setState with the waiting built in: call it on every change, and the newest value goes as soon as the last one is far enough behind. Values overtaken by a later one are never sent, which is safe because a state write replaces the whole state.
import { definePlugin } from '@phantomclient/plugin';
type Painted = { cells: string };
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const board = await phantom.aj.realtime.join<number, Painted>({ scope: 'room' });
let cells = board.state()?.cells ?? '';
return board.onMessage(({ data }) => {
cells += String(data);
if (board.isHost) void board.publish({ cells }).catch(() => undefined);
});
},
});It resolves once Phantom has taken a value at least as new as yours, so an overtaken value resolves with the write that overtook it. It rejects if that write fails or the channel closes first. Use setState when a value has to go now, whatever the rate, or when it's written once.
In a room channel, the state belongs to the room, not the player. Walking into another room gives you that room's state, or null, just as the member list changes.
In a plugin channel, the state belongs to the group of 200 the player is in, not everyone online. Each group is a separate channel. For one value everyone sees, use a named channel.
Presence
Each member has a small object of their own. It arrives with them, goes when they leave, and members() returns it with the name and id.
import { definePlugin } from '@phantomclient/plugin';
import type { RealtimeObject } from '@phantomclient/plugin/sdk';
type Me = { ready: boolean; colour: string };
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const lobby = await phantom.aj.realtime.join<unknown, RealtimeObject, Me>(
{ scope: 'named', name: 'lobby' },
{ presence: { ready: false, colour: '#7fd' } }
);
const stops = [
lobby.onJoin((member) =>
ctx.log(`${member.screenName} is ${member.presence?.colour}`)
),
lobby.onPresence((member) =>
ctx.log(
`${member.screenName} is ${member.presence?.ready ? 'ready' : 'not ready'}`
)
),
];
await lobby.setPresence({ ready: true, colour: '#7fd' });
ctx.log(`${lobby.members().filter((each) => each.presence?.ready).length} ready`);
return () => stops.forEach((stop) => stop());
},
});Passing presence to join means nobody sees the player without one. setPresence replaces it, null clears it, and onPresence tells you when someone else's changes. Your own is whatever you last set, in presence() and channel.you.presence.
Use presence for anything about one player: a score, a colour, whether they're typing, where their cursor is. Phantom sends the newest value a few times a second rather than every one you set, so you can set it as often as you like. In a room channel, presence follows the player between rooms.
For a value that changes with every mouse move, publishPresence is setPresence with the waiting built in, like publish for state: call it on every change, and the newest value goes as soon as the last one is far enough behind.
import { definePlugin } from '@phantomclient/plugin';
type Pointer = { x: number; y: number };
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const room = await phantom.aj.realtime.join<unknown, never, Pointer>({
scope: 'room',
});
const move = (x: number, y: number) =>
void room.publishPresence({ x, y }).catch(() => undefined);
move(100, 200);
return () => void room.leave();
},
});presence() and channel.you.presence read the newest value you gave it, sent or not.
Asking one member
ask sends a question to one member and waits for their copy of your plugin to answer.
import { definePlugin } from '@phantomclient/plugin';
import { RealtimeAskError } from '@phantomclient/plugin/sdk';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const lobby = await phantom.aj.realtime.join({ scope: 'named', name: 'lobby' });
lobby.onAsk<{ what: string }, { score: number }>(({ from, data }) => {
ctx.log(`${from.screenName} asked for my ${data.what}`);
return { score: 12 };
});
const [someone] = lobby.members();
if (someone) {
try {
const answer = await lobby.ask<{ score: number }>(someone.id, {
what: 'score',
});
ctx.log(`${someone.screenName} scored ${answer.score}`);
} catch (error) {
if (error instanceof RealtimeAskError) ctx.log(`No answer: ${error.reason}`);
}
}
return () => void lobby.leave();
},
});The handler's answer goes back to whoever asked. It can return a promise, so you can look something up first, and returning nothing answers null. A channel has one handler: calling onAsk again replaces it, and the function it returns removes it.
Phantom pairs each question with its reply, so you never match them up yourself and no plugin can answer a question it wasn't asked. The id is made on the asking computer and echoed back by the answering one, and neither copy of your plugin sees or sets it.
When there's no answer
ask rejects with a RealtimeAskError, whose reason says which of these it was.
| Reason | What happened |
|---|---|
timeout | Nobody answered in time. |
gone | The member left the channel, or was never in it. |
closed | The channel closed while the question was out. |
no-handler | Their copy of your plugin isn't listening for asks. |
failed | Their handler threw. |
The wait is ten seconds by default. Pass your own with ask(member.id, question, { timeout: 30000 }), anywhere from 250 ms to a minute.
gone and closed don't wait for the timeout. Phantom knows who's in the channel, so a question to someone who has left, or on a channel that closes, fails straight away. A question to someone not in members(), or on a channel with nobody else in it, fails with gone before it's sent, and one asked while the channel is rejoining fails with closed. no-handler is immediate too: a game with nothing listening for asks turns one down as soon as it arrives.
A handler that throws sends failed and nothing else. The error stays in that player's copy of your plugin's log, so a stack trace never reaches someone else's game. A listener that throws (onMessage, onJoin and the rest) logs the same way, and the other listeners still get the event.
The question and the answer are both messages. A question is up to 4 KB as JSON and uses one of your plugin's ten a second, and the answer uses one of the answering copy's ten. Asking gives no more throughput than sending.
If you open a channel with open rather than join, answer its asks yourself with phantom.aj.realtime.onAsk and answer or decline. A question nobody answers times out on the asker's side.
Attachments
A channel carries JSON, and only a few KB of it. Send a picture as an attachment instead: you get back a small RealtimeAttachment to put in a message, the state or your presence like any other value, and whoever receives it turns it back into the picture.
import { definePlugin } from '@phantomclient/plugin';
import { isRealtimeAttachment } from '@phantomclient/plugin/sdk';
declare const drawing: Uint8Array;
declare const img: HTMLImageElement;
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const gallery = await phantom.aj.realtime.join({ scope: 'named', name: 'gallery' });
const picture = await gallery.attach(drawing, { type: 'image/png' });
await gallery.send({ kind: 'drawing', picture });
gallery.onMessage(async ({ data }) => {
const sent = (data as { picture?: unknown }).picture;
if (isRealtimeAttachment(sent))
img.src = await phantom.aj.realtime.attachmentUrl(sent);
});
return () => void gallery.leave();
},
});- Formats: PNG, JPEG, WebP or GIF, up to 2 MB and 4096 pixels a side. Phantom detects the format from the bytes, not the
typeyou give;typeonly makes it refuse a file that isn't that format. PNG and WebP must be still images; send animations as GIF. - What's kept: only the picture. Metadata (like a photo's EXIF location), comments and anything appended after the image are stripped before anyone else sees it.
- How long: a day from when it was last attached.
expiresAton the reference says when. Attaching the same file again gives it another day. - Duplicates are free: the same file attached in the same channel is the same attachment, uploaded and counted once however many members attach it.
- Scope: an attachment belongs to your plugin and the channel it was attached in (for a room channel, the room at the time). Anyone with the reference can fetch it, and only your plugin's channels carry it.
- Fetching:
phantom.aj.realtime.attachmentUrl(attachment)gives ablob:address for an<img>, andattachmentBytes(attachment)gives the bytes. Each is fetched once and cached for a while, so ten panels showing the same picture cost one download. Both reject for an expired one. - Rate: 20 attachments a minute for your plugin, and 50 MB of new pictures a day per player account. Past either,
attachrejects and says which.
Attaching needs realtime at act, as sending does, and fetching one back needs read.
The host
Every channel with someone in it has a host: one member picked by Phantom's server, the same for everyone in the channel. You learn who it is when you join, and another member takes over as soon as the host leaves.
Games need this. Phantom's server doesn't understand what your plugin sends; it passes messages on as they came, and any member can write the state, so two copies could decide the same round differently. The host is the one copy everyone agrees on. Treat it as the referee: it works out whose turn it is, who won the round and what goes in the state, and the others draw what it says.
import { definePlugin } from '@phantomclient/plugin';
type Round = { number: number; word: string };
const WORDS = ['otter', 'lynx', 'heron'];
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const game = await phantom.aj.realtime.join<{ guess: string }, Round>({
scope: 'named',
name: 'word-game',
});
const nextRound = async () => {
if (!game.isHost) return;
const number = (game.state()?.number ?? 0) + 1;
await game.setState({ number, word: WORDS[number % WORDS.length]! });
};
const stops = [
game.onMessage(({ from, data }) => {
if (!game.isHost) return;
if (data.guess === game.state()?.word) {
ctx.log(`${from.screenName} got it`);
void nextRound();
}
}),
game.onHost(({ you, host }) => {
ctx.log(
you ? 'You are refereeing' : `${host?.screenName ?? 'Somebody'} is refereeing`
);
void nextRound();
}),
];
await nextRound();
return () => {
stops.forEach((stop) => stop());
void game.leave();
};
},
});channel.isHostis whether the player is the host. Only referee when it's true.channel.host()is the host as a member: the player, or one ofmembers().channel.hosting()returns{ host, you, hosted, hidden }from the same moment, for a line of UI about the referee. It's the same shapeonHostpasses, so the first draw and every redraw use the same object.channel.onHost(...)hears it change, withyoufor whether it's now the player.channel.giveHost(id)hands it to someone else. Only the host can.
The first member in is the host. When the host leaves, whoever has been in the channel longest takes over. Phantom's server decides and tells everyone the same thing, so two copies can never disagree about who it is.
A member can't claim it. The only host message a plugin can send is the host handing it to another member, and Phantom only accepts that from the host. A modified copy of your plugin can lie about anything else it sends, but not this.
When the host leaves
Plan for this gap. A host that quits, closes the game or loses its connection stops refereeing before anyone knows, and Phantom names the next one when it notices. Everyone else gets onLeave and then, a moment later, onHost. In between, nobody is refereeing, and any round the old host was judging is lost.
What to do about it:
- Keep the referee's decisions in the channel's state, not in the host's memory, so the new host carries on from
state()instead of starting again. - Treat
onHostwithyouas the moment to take over: read the state, carry on from there, and call the round again if needed. Don't assume you saw everything the old host did. - Expect messages the old host sent before it left to arrive after you've become host. Keep a round number in the state and check it before acting.
- An
askto a host that has already gone fails withgonerather than hanging, which is the quickest way to find out the referee has changed. - Don't referee because
hostedis false. A channel the player is in always has a host; the only question is whether it's them.
Passing it on
A host can pass the role to another member with giveHost. Do this when you know you're leaving before Phantom does, or when a game wants a different referee each round.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const game = await phantom.aj.realtime.join({ scope: 'named', name: 'word-game' });
const [next] = game.members();
if (game.isHost && next) await game.giveHost(next.id);
return () => void game.leave();
},
});Phantom only accepts it from the host, and only for someone in the same channel. Everyone hears it through onHost, the old host included. It counts as one of your ten messages a second.
Channels that don't need a host
Every channel has a host whether it needs one or not. A channel your plugin only chats on can ignore it. There's no join option to turn it off, because if some members had asked for a host and others hadn't, they'd disagree about whether there was one.
Listening to all channels
The channel object is the simplest way in. If your plugin has several channels open, phantom.aj.realtime has the same listeners for all of them.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
setup(ctx) {
const phantom = ctx.phantom();
return phantom.aj.realtime.onMessage(({ channel, from, data, direct }) => {
ctx.log(
`${from.screenName} on ${channel}${direct ? ' (just to you)' : ''}:`,
data
);
});
},
});It also has onJoin, onLeave, onAsk, onHost, onClose and onStatus. open, send, sendTo, ask, attach, leave, giveHost, host, channels and members take a channel id if you'd rather not keep the object, and answer and decline take the id an ask came with. A channel opened by id rather than with join doesn't rejoin by itself.
When a channel closes
onClose gives one of these reasons, plus a message you can show the player.
| Reason | What happened |
|---|---|
left | Your plugin left it, or the last of its frames in that game closed or reloaded. |
banned | The player's account was barred from the Phantom community. |
killed | Phantom staff switched off your plugin's real-time channels. |
rate-limited | It sent faster than Phantom allows. You won't see this if you stay in the limits. |
offline | Phantom refused the channel back after the connection dropped; code says why. |
A closed channel doesn't send or receive anything; join again when you're ready. A channel from join only closes as offline when Phantom refuses it outright while rejoining: the player was barred (code is BANNED), or your plugin's version was taken out of the store (NOT_LISTED). A dropped connection on its own never closes it.
When the connection drops
A channel from join doesn't close when the connection drops. It drops out for a moment and rejoins by itself once the connection is back, as the same object with every listener still attached, so there's nothing to set up again.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const lobby = await phantom.aj.realtime.join({ scope: 'named', name: 'lobby' });
lobby.onRejoin(({ state }) =>
ctx.log(state === 'rejoining' ? 'Reconnecting…' : 'Back in the lobby')
);
return () => void lobby.leave();
},
});- When it drops,
onRejoinsaysrejoiningand everyone in the channel is heard leaving throughonLeave, just as they hear the player leave. While it's away,channel.rejoiningistrue,members()is empty, andstate()is what it was when it dropped. - What your plugin sends meanwhile (
send,sendTo,setState,setPresence,attach) isn't lost. It's queued in order and sent once the channel is back, spaced to fit your ten a second. Up to 32 sends are queued; past that the newest is refused and logged.publishandpublishPresencesend the newest value once it's back. - An
askisn't queued. It fails straight away withclosed, since nobody could answer. - On return, everyone there is heard arriving through
onJoin, a changed state throughonStateand a new host throughonHost, thenonRejoinsaysrejoined. Each member keeps theirid. The rejoin carries the player's current presence, and in a channel with parts it may land in a different part with a newid, so readchannel.idwhen you need it rather than storing it. - It keeps trying as long as it takes, whether that's a server update, a network blip or a laptop asleep overnight: after a second, then two, four and so on, never more than thirty apart (each slightly randomised, so everyone dropped by a restart doesn't reconnect at once), and immediately whenever Phantom says the connection is back. Each failed try is logged.
- Messages are never delivered twice: what was said while the player was away isn't replayed, and nothing from the old channel reaches the new one.
left, banned, killed and rate-limited are deliberate and close the channel for good.
Because everyone hears the player leave and come back, anything kept per member can restart cleanly on both sides. A voice or video call signalled over a channel is the usual case: close the peer connection in onLeave and offer again in onJoin, as for anyone new, and the call comes back by itself after a drop. Or keep the peers and tell a reconnect from a real goodbye with channel.rejoining, which is true while those leaves are heard.
import { definePlugin } from '@phantomclient/plugin';
declare function call(memberId: string): { close(): void };
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const voice = await phantom.aj.realtime.join({ scope: 'named', name: 'voice' });
const peers = new Map<string, { close(): void }>();
for (const member of voice.members()) peers.set(member.id, call(member.id));
voice.onJoin((member) => peers.set(member.id, call(member.id)));
voice.onLeave((member) => {
peers.get(member.id)?.close();
peers.delete(member.id);
});
return () => void voice.leave();
},
});The connection's status
phantom.aj.realtime.status() says how the connection behind your channels is doing, and onStatus hears it change:
| State | What it means |
|---|---|
off | Your plugin isn't in a channel, so nothing is connected for it. |
connecting | Phantom is connecting, or connecting again after a drop. |
online | Connected. |
offline | The connection dropped, and Phantom is reconnecting by itself. |
refused | Phantom's server turned the player away: signed out, no plan, or barred. |
error is a message to show with offline and refused. onReconnect is called once each time the connection comes back after a drop (or after being refused and signing in again), which is when to set up anything of your own outside a channel. A channel from join doesn't need it: it rejoins by itself, and onRejoin says when.
import { definePlugin } from '@phantomclient/plugin';
declare function show(words: string): void;
export default definePlugin({
setup(ctx) {
const phantom = ctx.phantom();
const stops = [
phantom.aj.realtime.onStatus(({ state, error }) => {
if (state === 'offline') show('Reconnecting…');
else if (state === 'refused') show(error ?? 'Not connected');
else show('');
}),
phantom.aj.realtime.onReconnect(() => ctx.log('Back online')),
];
return () => stops.forEach((stop) => stop());
},
});Phantom notices a drop however it happens: the socket closing, the server going quiet (a ping not answered within ten seconds, since a socket left over from a laptop's sleep never closes by itself), or the computer waking up, when it checks the connection straight away. A send on a channel from join is queued while it's away, as above. A send by channel id (phantom.aj.realtime.send) while the connection is down is refused immediately, with the reason.
Staying in a channel
A channel from join survives a dropped connection by itself. stayIn is join that also rejoins if the channel ever closes as offline, for as long as your plugin wants the channel.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
return await phantom.aj.realtime.stayIn({ scope: 'room' }, (room) => {
room.onMembers((members) => ctx.log(`${members.length} here`));
room.onMessage(({ from, data }) => ctx.log(`${from.screenName}:`, data));
});
},
});A channel's listeners go when it closes, so put them in ready. It's called with the first channel and with each new one stayIn joins after a channel closed as offline. A plain drop is handled by the channel itself, so ready isn't called again for it. The first join is awaited and its refusal thrown, like join: a plugin not from the store, a full named channel or a player who isn't signed in gets an error rather than waiting.
Only offline is rejoined, and not when its code is an outright refusal (BANNED, NOT_LISTED). For left, banned, killed and rate-limited it stops. Once a channel has given up, stayIn tries again after five seconds, then ten, twenty and thirty, and every thirty after that. onProblem hears about every failed rejoin, including the channel's own; without it they go to your plugin's log. The function it returns stops rejoining and leaves the open channel.
Limits
- 4 KB per message, measured as JSON. Anything JSON can represent is fine. A question and its answer are each a message, and each held to the same 4 KB.
- 4 KB for a channel's state, and 512 bytes for one member's presence, measured the same way. Both have to be a plain object, or
nullto clear them; anything else is rejected. Presence is smaller because joining a full channel carries 200 of them at once. - 10 messages a second for your plugin, counted across all games open in the app. Over that,
send,askandgiveHostreject straight away and nothing is sent. An answer comes out of the answering copy's ten, so an exchange costs each side one.setStateandsetPresenceuse the same ten but are never rejected for it: they're values, not events, so Phantom sends the newest one waiting, up to four times a second per channel, and overtaken values are never sent. - 8 channels for your plugin in each game. Joining a channel your plugin is already in, or still joining, gives you the same channel back instead of a second join, so two panels can both join the room channel. It's only left once each of those joins has called
leave(). - 60 joins a minute for the whole app. Only accepted joins count. Phantom never retries a refused join for you:
joinrejects with the reason and you can try again when it makes sense. If the same join keeps being refused (120 a minute), the app is asked to back off for a moment. - 200 people in one channel: a named channel, or each group of a plugin channel. This limits how far one message travels, not how many people your plugin can gather. A named channel with
split: truetakes parts of 200, like a plugin channel. - 2 MB per attachment, 20 attachments a minute for your plugin, and 50 MB of new attachments a day for each player's account. An attachment is kept for a day.
- 32 sends waiting while a channel rejoins.
A channel closes by itself once nothing in that game is listening to it, so if a panel joins a channel and is then closed, the player isn't left in it.
The sizes and rates are readable, so a plugin can check what it's about to send against the limit instead of hard-coding numbers from this page:
const {
messageBytes,
stateBytes,
presenceBytes,
messagesPerSecond,
updatesPerSecond,
members,
attachmentBytes,
attachmentTypes,
attachmentLifetimeMs,
} = phantom.aj.realtime.limits;phantom.aj.realtime.limits is available before anything is joined, in scripts as well as plugins.
Store plugins and Developer mode
Real-time channels work for plugins from the Phantom store and plugins running in Developer mode. Anything else is refused with a message saying so.
A store plugin can only join from a version that's live in the store and was reviewed with realtime in its permissions. realtime is high risk at both levels, so someone reviews each version before it's listed.
In Developer mode your plugin can join straight away so you can build and test it, but it only meets other copies running in Developer mode, never people using the published version. The easiest way to test is two games in split view on two accounts.
What Phantom does with messages
Phantom's server doesn't filter or change what your plugin sends. It passes messages on as they are, within the limits above.
For safety, Phantom staff can watch real-time plugin traffic live, in the same moderation view they use for the community. They see the plugin, the channel, who sent each message and who received it (by in-game name only), and the content. Questions and answers are in the same feed, marked and paired. Channel state and presence are there too: what each channel and member holds now, and every change. They also see which member is each channel's host, and each handover.
For this, the server keeps the last half hour in memory, plus each channel's state and its members' presence for as long as someone is in the channel. It drops them after that, or when it restarts. Nothing a plugin sends is written to a database.
Attachments are the exception: the picture is kept in Phantom's storage, with a note of the plugin and channel, whose account attached it, what it is and when it expires. Both are deleted when it expires, a day after it was last attached. A message carrying one shows staff the reference like anything else.
Staff can switch off a plugin's real-time channels. That closes all its open channels with killed, refuses new channels and attachments, and stops its attachments being fetched, until staff switch it back on.
Bans and hiding apply too. An account barred from the community can't join. If a player has hidden someone, or been hidden by them, that person never appears in their channels, their presence never arrives, and nothing either sends reaches the other. They can't ask each other either: they're not in each other's members(), so ask fails with gone before anything is sent. Your plugin can let players hide someone with phantom.aj.community.hide, by screen name (see Custom rooms).
Hiding can't remove the host, because everyone in the channel must have the same one. If the player has hidden the host, or the host has hidden them, channel.host() is null, channel.hosted is true and channel.hostHidden says so: there's a referee, but they're not told who. Show this to the player, because unlike the gap between two hosts it doesn't fix itself. Neither learns the other is there, and isHost is false. Both can still play a game refereed through the channel's state, which everyone can follow. A game played by asking the referee won't work, since they aren't in each other's members(), which is the point of hiding.
Channel state is the other exception, because it belongs to the channel, not to whoever wrote it. Holding a write back would leave the two looking at different boards for good, so state reaches everyone, with only its from withheld so neither learns the other is there. For that reason, keep anything a player typed out of channel state and send it in a message or presence, which hiding does cover.
Players can report a plugin for what it does with its channels. A store plugin that uses them says so on its page in Phantom, with a Report its live messages button, and the plugin's store page on the website has the same button.
Catching up when you join
Messages don't carry anything from before you joined. Someone joining a room an hour late hears the next message, not the last hundred. Use channel state for a value everyone shares and presence for a value about one player; both arrive with the join, as does the host.
If neither fits, ask a member who's already there. It fails rather than hanging when nobody answers. Use it when a value really doesn't belong in 4 KB of shared state.