Platform API: transfer, Discord, HTTP, scheduler, state, queue, idle
These additions sit next to the server API in server.md. They are installed by the platform layer of every JavaScript package (script-js/src/resource_platform.js) before the package's own code runs, and everything they start belongs to the package: timers, requests and sign-in attempts end when the package stops. C# equivalents are listed at the end.
Everything here is off or inert until the owner configures it in conf.json (server-configuration.md (docs/server-configuration.md)). Without configuration, calls fail with a reason (throw or {ok: false, reason}); none of them are silently ignored.
player.transfer(destination, options?)
Moves the player to another instance of the same cluster (cluster.md (docs/cluster.md)).
mp.events.addCommand('lobby', (player) => player.transfer('eu-2', { data: { spawn: 'airport' } }));
mp.events.add('playerTransfer', (player, info) => spawnAt(player, info.data?.spawn)); // destination
mp.events.add('playerTransferFailed', (player, info) => player.outputChatBox(`Staying here: ${info.reason}`)); // source
destinationis an id fromcluster-instances. Unknown destinations and servers withoutcluster-id/cluster-key-filethrow at once.datais any JSON value, at most 4096 bytes. It reaches the destination unaltered: the client carries it, but its digest is inside the signed ticket.- The client connects to the destination before leaving this server. If the
destination refuses (full, draining, wrong ticket) or does not answer within
20 seconds, the player stays and
playerTransferFailedfires here withreasonrefused,unreachableortimeout(timeoutalso when the client never follows). - On the destination,
player.transferredFromnames the source duringplayerJoin;playerTransferfollows with{from, data}. - On the source the player leaves with
playerQuit(player, 'transfer', '').
player.discord
Server-controlled Discord presence and server-side Discord sign-in (discord.md (docs/discord.md)).
player.discord.setPresence({ details: 'Racing', state: 'Lap 1/3', largeImageKey: 'track_docks',
startTimestamp: new Date(), partySize: 1, partyMax: 8 });
player.discord.updatePresence({ state: 'Lap 2/3' }); // merges with what this package set
player.discord.clearPresence();
const result = await player.discord.authenticate({ scopes: ['identify'], guild: true });
if (result.ok) linkAccount(player, result.user.id); // player.discordId is now the verified ID too
else player.outputChatBox(`Discord sign-in: ${result.reason}`);
- Presence text fields are at most 128 characters; image fields are asset keys
(
a-z,0-9,_,-) uploaded to your Discord application, never URLs. Presence is cleared automatically when the player leaves or transfers, and when the package that set it stops. Players can turn presence off in the launcher; then nothing reaches Discord. authenticateresolves with `{ok: true, user: {id, username, globalName, avatar}, guild?: {member, roles}}or{ok: false, reason}`. Passroles: ['<role id>', …](at most 25) to learn which of those roles the member holds:guild.rolesis that subset, never the member's full role list. Withoutrolesit is empty.reasonis one ofUSER_CANCELLED,TIMEOUT,OAUTH_ERROR,INVALID_STATE,TOKEN_EXCHANGE_FAILED,IDENTITY_FAILED,SERVER_NOT_CONFIGURED,SUPERSEDED(a newer attempt for the same player),PLAYER_LEFT,PACKAGE_STOPPED.INVALID_STATEcallbacks cannot be attributed to a player; the attempt then ends withTIMEOUTafter three minutes.player.discordIdisnulluntil a sign-in succeeds. A client cannot set it.
mp.http
Outgoing HTTP from a package, under the owner's policy (http-* keys).
const answer = await mp.http.get('https://api.example.org/bans/' + id, { responseType: 'json', timeout: 5000 });
await mp.http.post('https://hooks.example.org/report', { player: name }); // JSON body
const pending = mp.http.get(url); pending.cancel(); // cancellation
- Methods: GET, POST, PUT, PATCH, DELETE. Responses are `{status, ok, url,
headers, body}
;responseTypeistext(default),jsonorbuffer`. - Defaults: timeout
http-timeout-ms(10 s, at most 120 s), response limithttp-max-response-bytes(4 MiB). Bigger responses are aborted. - Owner policy:
http-allowed-hosts(exact names or*.domain; empty means any host, as before) andhttp-block-private(refuses names that resolve to loopback, private, link-local, CGNAT or multicast addresses). Redirects are followed by hand, at most five, and every hop is checked against the policy. The private-address check applies to the address the request actually connects to (every address the name resolves to, at connect time), so a DNS answer that changes between a check and the request (DNS rebinding) cannot reach a private address. Literal addresses in any spelling (decimal, hex, octal, IPv4-mapped IPv6, NAT64) are checked too. See security/http.md (docs/security/http.md). - Responses are not decompressed; the size limit counts bytes on the wire.
Cross-origin redirects drop
authorizationandcookie, and 303 (or POST on 301/302) continues as GET without a body. mp.http.stats()and the per-package metricsnextmp_resource_http_requests_total/nextmp_resource_http_failures_totalcount requests.mp.http.redactHeaders(headers)returns a copy with authorization and cookie values replaced, for logging.- In-flight requests are aborted when the package stops.
mp.schedule
Wall-clock timers owned by the package.
const id = mp.schedule.every({ minutes: 5 }, saveAll);
mp.schedule.at('2026-12-31T23:59:00Z', fireworks);
mp.schedule.daily('04:00', restartEconomy, { timeZone: 'UTC', persist: 'economy-restart', missed: 'run' });
mp.schedule.cancel(id);
mp.schedule.list(); // [{id, name, kind, next}]
- Times are recomputed from the wall clock at every firing, so a clock change or a long stall does not shift the schedule permanently. Intervals are at least 100 ms.
dailyusesUTC(default) orlocal. Inlocaltime a time that does not exist on a spring-forward day runs an hour later; a repeated time on a fall-back day runs once, at the first occurrence.- Missed executions: while the server runs, a late timer runs once, not once per
missed period. Across restarts nothing is remembered unless you pass
persist: '<key>': the last run is kept indata/schedule/<package>.json, and withmissed: 'run'one missed occurrence runs at start (skip, the default, does not). - All jobs end when the package stops.
mp.state
State across a package restart that the operator explicitly asks to preserve (admin.cjs restart --package NAME --preserve-state true, or the dashboard). Development-first: production servers restart without it unless asked.
mp.state.onSerialize(() => ({ round, scores: [...scores] })); // plain JSON, at most 1 MiB
mp.state.onRestore((state) => { round = state.round; scores = new Map(state.scores); });
- Store ids, not objects: players, vehicles and timers do not survive a restart.
onRestoreruns immediately when preserved state is waiting. If it throws, the package start fails and the package stays stopped (never half-alive); the error says to restart withoutpreserveStateto begin fresh.- State not taken by the new instance is discarded with a warning.
- Across a server restart:
"preserve-state-on-restart": trueinconf.json(off by default) runs every package's serializer on a clean stop and writesdata/state/<package>.json; the next start hands each file toonRestoreonce and deletes it. Copying those files to another instance before it starts is how a whole package's state moves between instances; per-player state moves with transfer data. - C# packages do not have state preservation.
mp.queue
When join-queue is on and the server is full, joiners wait in a queue instead of being refused (cluster.md (docs/cluster.md)).
mp.events.add('queueJoin', (info) => { if (isStaff(info.socialName)) mp.queue.setPriority(info.ticket, 100); });
mp.queue.setText(0, 'Event in progress — thanks for waiting');
mp.queue.deny(ticket, 'Whitelist only');
mp.queue.enable(false); // overrides conf.json until the next restart
Priority is a plain number; higher goes first, equal priorities keep arrival order. There is no built-in VIP logic.
mp.afk and idleness
player.idleSeconds counts seconds since the server last saw the player move more than half a metre, turn more than 10°, change weapon, vehicle or state flags, chat or send an event. The server never kicks for idleness by itself.
const stop = mp.afk.watch({ seconds: 900, warnSeconds: 840, onWarn: (p) => p.outputChatBox('Move or be kicked'), kick: true });
kick: true uses player.kickIdle(reason), so playerQuit names the reason afk.
Disconnect reasons
playerQuit(player, reason, text): reason is quit, lost (timeout, crash or an old client — the transport cannot tell them apart), kicked, afk, shutdown or transfer; text carries the kick reason. Two-argument handlers keep working.
C
| JavaScript | C# |
|---|---|
player.transfer(id, {data}) | player.Transfer(id, data) |
player.transferredFrom, player.discordId | player.TransferredFrom, player.DiscordId |
player.discord.setPresence / clearPresence | player.SetDiscordPresence(new DiscordPresence(...)) / (null) |
playerTransfer, playerTransferFailed, playerDevice, playerIntegrity, queueJoin, queueLeave, serverDrain | Events.PlatformNotice += (name, player, data) => … |
mp.http, mp.schedule | HttpClient, System.Threading.Timer (owned by the package's own code) |
player.discord.authenticate, mp.state, mp.queue | not available in C#; use a JavaScript package |