JavaScript/TypeScript API

Overview

The JavaScript and TypeScript bindings can be used to run Realtime Core in browsers and in Node.js. It is the same native client core that ships for every other platform, compiled to WebAssembly and wrapped in a TypeScript API that behaves like any other asynchronous JavaScript library: operations return promises, events are typed and values are plain JavaScript. The package works from TypeScript and from plain JavaScript and carries its own type declarations, see TypeScript.

Three C++ mechanisms have JavaScript equivalents.

C++ mechanism JavaScript equivalent
Task<Result<T, E>> returned by an async operation A Promise that resolves with the result and rejects with a RealtimeError, see Errors.
Broadcaster subscriptions such as OnPlayerJoined Named events: client.on('playerJoined', handler), client.once(), and client.onEvent() for custom events, see Events.
Rich types such as RealtimeMap, RealtimeValue or Player Plain JavaScript values: objects, numbers, bigint, strings and typed arrays, see Values.

The rest of the Realtime Core documentation describes the behavior, this page describes how to reach it from JavaScript and TypeScript. Every tabbed code sample in the manual has a JavaScript tab beside its C++ tabs that shows the same operation with this package. The samples are JavaScript, and the same code works in a TypeScript project. The package also includes TypeScript declarations, the TypeScript section talks more about the usage in TypeScript.

What the JavaScript Samples Assume

The JavaScript code samples in the documentation are ES modules with top-level await. They assume the following names exist in your code to keep the snippets as short as possible.

Name What it is Where it comes from
client A RealtimeClient, connected or in a room when the section implies it createRealtimeClient(), see below
client.room The Room the client is in, or null The client, see Rooms and Players
clientA, clientB Two clients in the same room, in samples that need two createRealtimeClient(), once per client

A sample that uses any other export of the package, such as isRealtimeError or a value helper like byte, imports it from @photonengine/realtime-core in its first lines. 'your-app-id' stands for the App Id from the Photon Dashboard.

The client of the samples is created like this:

JavaScript

import { createRealtimeClient } from '@photonengine/realtime-core';

const client = await createRealtimeClient({ appId: 'your-app-id', appVersion: '1.0' });

appId and appVersion are the only required options, and clients only meet clients with the same App Id and app version. The other options mirror the C++ ClientConstructOptions; see Configuration.

Install

Install the package from npm:

npm install @photonengine/realtime-core

Under Node.js the WebSocket transport also needs the ws package:

npm install ws

The package is an ES module.
It runs in the browser, in Web Workers and in Node.js 22 or later, bundled or not.
Under Node.js a client keeps the process alive while its pump runs, which is from its creation unless it is created with autoPump: false, and dispose() stops the pump and lets the process exit.

Loading the Module

The WebAssembly module loads on the first createRealtimeClient() call, and one module instance serves every client in the page or process. Importing the package costs nothing until then. To load the module at a moment of your choosing, for example behind a loading screen, call preloadRealtime() first. getVersion() resolves to the Realtime Core version compiled into the module and the Photon protocol version it speaks.

Preloading the module and reading its version:

JavaScript

import { getVersion, preloadRealtime } from '@photonengine/realtime-core';

await preloadRealtime();

const { native, protocol } = await getVersion();
console.log(`Realtime Core ${native}, protocol ${protocol}`);

Bundlers and the .wasm File

The package ships the loader realtimecore.mjs and the module realtimecore.wasm side by side, and the loader finds the .wasm file through import.meta.url. Vite and webpack 5 understand that pattern and emit the file as an asset, so a project bundled with them needs no configuration. The module contains no eval and no new Function, so it loads under a strict Content Security Policy.

With any other bundler, or when your setup serves the file elsewhere, serve realtimecore.wasm yourself and pass its URL to preloadRealtime({ url }) or as the wasm: { url } option of createRealtimeClient(). The package exports the file as @photonengine/realtime-core/realtimecore.wasm, so a bundler's asset import, such as Vite's @photonengine/realtime-core/realtimecore.wasm?url, can supply the URL. To fetch the bytes yourself, for example for your own caching, pass them as binary instead of url. The module loads once per page or process, so pass the location on the first preloadRealtime() or createRealtimeClient() call. After that, a call that passes a location other than the one the first call passed rejects, and every other call uses the module already loaded.

Loading the module from bytes you fetched yourself:

JavaScript

import { preloadRealtime } from '@photonengine/realtime-core';

const response = await fetch('/static/realtimecore.wasm');
await preloadRealtime({ binary: await response.arrayBuffer() });

The Pump

The native client does nothing on its own: no packet is read, no promise settles and no event fires until its service function runs. The package calls it for you with a timer, every 16 milliseconds by default, so the service loop the Client and Service Loop page describes needs no code of your own.

Option or member Effect
autoPump: { intervalMs: 33 } Creates the client with a different timer interval.
autoPump: false Creates the client without a timer; call client.service() yourself once per frame.
client.pump.start(), client.pump.stop() Starts or stops the timer at any time.
client.pump.intervalMs Reads or changes the timer interval, also while the timer runs.

Driving the client from your own game loop instead of the timer:

JavaScript

import { createRealtimeClient } from '@photonengine/realtime-core';

const client = await createRealtimeClient({ appId: 'your-app-id', appVersion: '1.0', autoPump: false });

function frame() {
    client.service();
    // ... update and render the game ...
    requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

client.service() is the whole pump, and it throws when called from inside an event handler. The granular C++ pump steps such as ServiceBasic() and DispatchIncomingCommand() have no JavaScript counterpart.

The timer is a setInterval, not requestAnimationFrame, because animation frames stop in a background tab and the server would drop the connection. Browsers throttle the timers of a background tab to once a second, and after five minutes to once a minute. If your game must stay connected through that, raise disconnectTimeoutMs or run the client in a Web Worker, where timers are not throttled.

Everything runs on the JavaScript event loop: events fire and operations complete during service(), and there is no background send and receive thread.

Errors

Every failed operation rejects with a RealtimeError. Its code is a readable name such as 'roomNotFound', codeValue is the number the server or the client reported and message explains the failure. A number the package does not know reads as the code 'unknown', and codeValue still carries it. The ErrorCode reference lists every code with both forms.

Each operation reports only its own subset of the codes, and the package exports each subset as a string union type, such as ConnectErrorCode for connect() and JoinRoomErrorCode for joinRoom(). The Operation Error Enums reference lists the codes of every operation. RealtimeError is generic over its code type, RealtimeError<C extends ErrorCode = ErrorCode>. TypeScript cannot type a promise rejection, so the declaration of each operation names its code type in its documentation comment, for example connect() rejects with a RealtimeError<ConnectErrorCode>.

Catching a failed operation:

JavaScript

import { isRealtimeError } from '@photonengine/realtime-core';

try {
    await client.joinRoom('no-such-room');
} catch (error) {
    if (!isRealtimeError(error)) {
        throw error; // a bug in the calling code, not a Photon failure
    }
    console.log(error.code, error.codeValue, error.message);
}

Only failed operations are RealtimeError, whether the network, the server or the client's own state refused them. Misusing the API, such as calling a disposed client or passing a value that cannot be sent, throws a plain Error, TypeError or RangeError synchronously, so a RealtimeError always means that an operation failed. isRealtimeError() is a type guard that also recognizes a RealtimeError from a second copy of the package, where instanceof fails.

Misuse throws synchronously, before anything is sent:

JavaScript

import { isRealtimeError } from '@photonengine/realtime-core';

try {
    client.sendEvent(256, 'hello'); // event codes are bytes, 0 to 255
} catch (error) {
    console.log(error instanceof RangeError, isRealtimeError(error)); // true false
}

Disposing the client rejects every pending operation with a RealtimeError of code 'disconnected'. A client.once() whose timeoutMs elapses rejects with a DOMException named 'TimeoutError', the error AbortSignal.timeout() uses as well, and not with a RealtimeError, because a local wait timing out is not a Photon failure. Errors that occur outside a pending operation arrive as the error event, whose payload is a RealtimeError with one of the ClientErrorCode codes. Errors and Disconnects covers how to react to each failure.

Events

client.on(name, handler) subscribes to a client event and returns a function that unsubscribes, and client.off(name, handler) removes a handler by reference. Every event delivers one payload object, or nothing, so handlers destructure it. client.once(name) returns a promise of the next occurrence. The client callbacks reference lists every event with its payload. The warning event, for example, delivers { code, message }, where code is a WarningCode string such as 'outgoingReliableQueueFilling'.

A listener, and one event awaited as a promise:

JavaScript

const off = client.on('playerJoined', ({ player }) => {
    console.log(`player #${player.number} joined`);
});

const { playerNumber, isInactive } = await client.once('playerLeft', { timeoutMs: 60000 });
console.log(`player #${playerNumber} left${isInactive ? ' and may rejoin' : ''}`);
off();

The listener option { once: true } removes a listener after its first call, and { signal } removes it when an AbortSignal aborts, so one AbortController removes every listener of a scene at once. client.once() takes a signal as well. These cover what the C++ scoped subscriptions and subscription bags do, Callbacks and Subscriptions shows both.

A handler that throws does not break the client or the other handlers: the exception is reported to window.onerror or process.on('uncaughtException') after the event is dispatched.

Custom Events

client.sendEvent(code, data, options) raises a custom event to the other players in the room and returns whether the event was queued. Other clients receive it through onEvent(code, handler), onEventRange(firstCode, lastCode, handler) or onAnyEvent(handler), whose handlers get { code, senderNumber, data }. data arrives decoded as a value. With the option { asBytes: true } it arrives as the raw Uint8Array instead, for events sent with a Uint8Array payload or encoded by your own serializer. The C++ SubscribeEvent<T>() overload that decodes a trivially-copyable struct has no JavaScript counterpart: receive the bytes with asBytes and read them with a DataView.

Sending and receiving custom events:

JavaScript

const MOVE = 1;

client.onEvent(MOVE, ({ senderNumber, data }) => {
    console.log(`player #${senderNumber} moved to`, data);
});
client.onEventRange(100, 199, ({ code, data }) => console.log(`event ${code}: ${data.length} raw bytes`), {
    asBytes: true,
});

client.sendEvent(MOVE, { x: 10, y: 20 });

Custom Events describes the send options: reliability, channel, receivers, interest groups, caching and encryption.

Values

Custom properties, event payloads and matchmaking filters are plain JavaScript values. Photon's wire format is typed, so the package infers the wire type of each value.

JavaScript value Photon type
null, undefined null
boolean bool
integer number within 32 bits int32
any other number double
bigint int64
string string
Uint8Array byte[]
Int16Array, Int32Array, BigInt64Array int16[], int32[], int64[]
Float32Array, Float64Array float[], double[]
Array object[], whatever it holds
plain object hashtable with string keys
Map hashtable with inferred keys

When an exact type should be sent use bool(), byte(), int16(), int32(), int64(), float() and double() for numbers and booleans, strings() for a Photon string[], bools() for a bool[], dictionary(keyType, entries) for a typed dictionary and custom(code, bytes) for a custom type.

Setting room properties with inferred and explicit types:

JavaScript

import { byte, float, int64, strings } from '@photonengine/realtime-core';

client.room?.setProperties({
    map: 'desert', // string
    round: 3, // int32
    speed: 1.5, // double
    hp: byte(100), // byte, for a peer that reads exactly that type
    scale: float(0.5), // float
    seed: int64(2n ** 40n), // int64; a bigint infers to int64 as well
    tags: strings(['ranked', 'eu']), // string[]; a plain array would be object[]
});

Values you receive are plain JavaScript again: numbers, bigint for int64, typed arrays, arrays, a plain object for a hashtable with string keys, a Map for any other hashtable and for a typed dictionary, and a CustomValue for a custom type. Integer width is not preserved on read: the hp above reads back as 100, not as byte(100). Anything else, such as a Date, a class instance or a function, throws a TypeError naming the offending path before anything is sent. The data types reference maps every wire type to its JavaScript form.

Rooms and Players

client.room is the room the client is in, or null. It is a live view: room.players, room.playerCount, room.customProperties, room.isMasterClient and the other getters always reflect the current state, also inside an event handler. Every join or create promise resolves with the same Room object for as long as the client stays in the room. After leaving, room.isActive is false and its getters throw.

Joining a room and reading it from an event handler:

JavaScript

const room = await client.joinOrCreateRoom('my-room', { maxPlayers: 4 });

client.on('playerJoined', ({ player }) => {
    // the room already counts the player who joined
    console.log(`${player.name} joined: ${room.playerCount} of ${room.maxPlayers} players`);
});

console.log(room === client.room); // true: the same object until the client leaves

The room mutators setOpen(), setVisible(), setMaxPlayers(), setProperties(), removeProperties(), setLobbyProperties(), setExpectedUsers() and setMasterClient() return whether the request was sent, the result arrives as an event. setProperties(values, expected) is a compare-and-swap. client.localPlayer, room.players and room.getPlayer(number) return snapshots of the shape { number, name, userId, customProperties, isInactive, isMasterClient }.

Lifetime

client.dispose() stops the pump, rejects pending operations with a RealtimeError of code 'disconnected' and frees the native client. A pending disconnect() resolves instead, because disposing disconnects the client. It may be called from inside an event handler and more than once. On a disposed client, isDisposed, off(), the unsubscribe functions that the listener methods returned, pump.stop() and reading pump.isRunning or pump.intervalMs still work. Every other member throws an Error. A client dropped without dispose() is freed by the garbage collector eventually, but only while its pump is not running, so dispose every client explicitly.

Where the runtime supports explicit resource management, using disposes the client at the end of its scope, and await using disconnects it first, waiting at most two seconds:

JavaScript

import { createRealtimeClient } from '@photonengine/realtime-core';

async function playSession() {
    await using client = await createRealtimeClient({ appId: 'your-app-id', appVersion: '1.0' });
    await client.connect();
    // ... play ...
} // the client disconnects and is disposed here

Web Build Differences

The enums reference marks each omitted member that is not available..

Area Web build
Transport WebSocket only: protocol is 'ws' or 'wss', and 'wss' by default. Use 'wss' from an https:// page. UDP and TCP are not available.
Region selection The web build cannot ping regions, so regionSelection is 'default' or 'select' and there is no best-region mode. List the regions with availableRegions() and pick one with selectRegion(), see Connection and Regions.
Direct messaging The peer-to-peer modes are not available: directMessaging is 'none', 'allToAll' or 'masterToAll', relayed through the server. See Direct Messaging.
Threads A single thread: there is no background send and receive thread, and a timer runs the pump, see The Pump.
Logging Native log lines go to the console at the matching level, see Logging.

TypeScript

The package ships TypeScript declarations for every export, so a TypeScript project needs no separate @types package. Options, results, event names and event payloads are typed. A handler passed to client.on('playerJoined', handler) receives a typed { player }, and a misspelled event name or option fails to compile. Enumerations are string literal unions such as ConnectionState, ErrorCode and WarningCode, see the enums reference. The public types are exported for your own declarations, among them RealtimeClient, Room, Player, RoomListing, RealtimeError, the per-operation error code types such as JoinRoomErrorCode, ClientEvents, CustomEvent, RealtimeValue and Properties. Import them with import type from @photonengine/realtime-core.

A received value is where TypeScript asks for more than JavaScript. A custom event's data and a custom property's value are typed RealtimeValue, the union of every value Photon carries, because the sender decides what they hold. TypeScript requires narrowing such a value, for example with typeof or instanceof, before it is used as a specific type. A raw-bytes handler, registered with { asBytes: true }, receives data typed as Uint8Array and needs no narrowing. Under the compiler option noPropertyAccessFromIndexSignature, read a property as properties['map'] instead of properties.map.

Narrowing an event payload before using it as a string, in code that is both JavaScript and TypeScript:

JavaScript

const CHAT = 2;

client.onEvent(CHAT, ({ senderNumber, data }) => {
    // data is a RealtimeValue: narrow it before using it as a string
    if (typeof data === 'string') {
        console.log(`player #${senderNumber}: ${data.trim()}`);
    }
});

Full Example

The following complete program connects, joins or creates a room, sends a ping that the server delivers back to the sender, then leaves and disconnects:

JavaScript

import { createRealtimeClient, isRealtimeError } from '@photonengine/realtime-core';

const PING = 1;

const client = await createRealtimeClient({ appId: 'your-app-id', appVersion: '1.0' });
client.on('disconnected', ({ cause }) => console.log('disconnected:', cause));

try {
    await client.connect();
    const room = await client.joinOrCreateRoom('my-room', { maxPlayers: 4 });
    console.log(`in ${room.name} with ${room.playerCount} player(s)`);

    // receivers: 'all' delivers the ping to this client as well
    const sentAt = Date.now();
    const pingArrived = new Promise((resolve) => {
        client.onEvent(PING, resolve, { once: true });
    });
    client.sendEvent(PING, 'ping', { receivers: 'all' });
    await pingArrived;
    console.log(`ping arrived back after ${Date.now() - sentAt} ms`);

    await client.leaveRoom();
    await client.disconnect();
} catch (error) {
    if (!isRealtimeError(error)) {
        throw error;
    }
    console.error(`Photon said no: ${error.code} - ${error.message}`);
} finally {
    client.dispose();
}

Next Steps

Work through the manual with the JavaScript tab selected on each sample. Client and Service Loop and Asynchronous Operations cover the pump and the promise model, and Callbacks and Subscriptions explains the events. The Quick Start Guide builds a first connection step by step.

Last updated on

Back to top