---
sidebar_position: 1
---

# Player

### Get the player name

```
WA.player.name: string;
```

The player name is available from the `WA.player.name` property.

:::info
You need to wait for the end of the initialization before accessing `WA.player.name`
:::

```typescript
WA.onInit().then(() => {
    console.log('Player name: ', WA.player.name);
})
// Will display:
// Player name: Alice
```

### Get the player ID

```
WA.player.id: string|undefined;
```

The player ID is available from the `WA.player.id` property.
This is a unique identifier for a given player. Anonymous player might not have an id.

:::info
You need to wait for the end of the initialization before accessing `WA.player.id`
:::

```typescript
WA.onInit().then(() => {
    console.log('Player ID: ', WA.player.id);
})
// Will display:
// Player ID: a293c901-4455-4b1e-cf39-f4c0420de6f5
```

### Get the player language

```
WA.player.language: string;
```

The current language of player is available from the `WA.player.language` property.

:::info
You need to wait for the end of the initialization before accessing `WA.player.language`
:::

```typescript
WA.onInit().then(() => {
    console.log('Player language: ', WA.player.language);
})
// Will display:
// Player language: fr-FR
```

### Get the tags of the player

```
WA.player.tags: string[];
```

The player tags are available from the `WA.player.tags` property.
They represent a set of rights the player acquires after login in.

:::caution
Tags attributed to a user depend on the authentication system you are using. For the hosted version
of WorkAdventure, you can define tags related to the user in the [administration panel](/admin/members).
:::

:::info
You need to wait for the end of the initialization before accessing `WA.player.tags`
:::

```typescript
WA.onInit().then(() => {
    console.log('Tags: ', WA.player.tags);
})
```

### Know if the player is logged in

```
WA.player.isLogged: boolean;
```

Whether the current player is signed in, as opposed to playing anonymously, is available from the `WA.player.isLogged` property.

:::caution
Being logged in says nothing about what a player is allowed to do. Rights come from
[the player's tags](#get-the-tags-of-the-player), and a logged-in player may have none at all.
:::

:::info
You need to wait for the end of the initialization before accessing `WA.player.isLogged`
:::

```typescript
WA.onInit().then(() => {
    console.log('Logged in: ', WA.player.isLogged);
})
```

### Get the position of the player
```
WA.player.getPosition(): Promise<Position>
```
The player's current position is available using the `WA.player.getPosition()` function.

`Position` has the following attributes :
* **x (number) :** The coordinate x of the current player's position.
* **y (number) :** The coordinate y of the current player's position.


:::info
You need to wait for the end of the initialization before calling `WA.player.getPosition()`
:::

```typescript
WA.onInit().then(async () => {
    console.log('Position: ', await WA.player.getPosition());
})
```

### Get the woka of the player
```
WA.player.getWokaPicture(): Promise<string>
```

The player's Woka picture can be fetched using the `WA.player.getWokaPicture()` function.

This will return a promise resolving to a base64 encoded PNG of the Woka.
The Woka is facing south, in "standing" position.

### Get the user-room token of the player

```
WA.player.userRoomToken: string|undefined;
```

The user-room token is available from the `WA.player.userRoomToken` property.

This token can be used by third party services to authenticate a player and prove that the player is in a given room.
A typical use-case is a website opened in a co-website: it sends the token to its own backend, which verifies it before
doing anything on behalf of the player.

:::info
You need to wait for the end of the initialization before accessing `WA.player.userRoomToken`
:::

```typescript
WA.onInit().then(() => {
    console.log('Token: ', WA.player.userRoomToken);
})
```

The token is generated by the administration panel linked to WorkAdventure.
By default, self-hosted versions of WorkAdventure don't come with an administration panel, so the token will be empty.
If you developed your own administration panel, the token can be anything.

#### Verifying the token

On WorkAdventure SaaS (and on self-hosted installs of the WorkAdventure administration panel), the token is a
[JWT](https://jwt.io/) signed with RS256. Anyone can verify it using the public keys published by the administration
panel in [JWKS](https://datatracker.ietf.org/doc/html/rfc7517) format at `/.well-known/jwks.json`.
For WorkAdventure SaaS, this is `https://admin.workadventu.re/.well-known/jwks.json`.

The token contains these claims:

| Claim  | Description                                                                               |
|--------|-------------------------------------------------------------------------------------------|
| `room` | The URL of the room the player joined (e.g. `https://play.workadventu.re/@/org/world/room`) |
| `user` | The identifier of the player: their email if they are logged in, a UUID otherwise          |
| `iss`  | The URL of the administration panel that issued the token                                  |
| `aud`  | Same as `iss`                                                                              |
| `iat`  | When the token was issued                                                                  |
| `exp`  | When the token expires (48 hours after it was issued)                                      |

For instance, in Node.js, using the [jose](https://github.com/panva/jose) library:

```typescript
import { createRemoteJWKSet, jwtVerify } from "jose";

const ADMIN_URL = "https://admin.workadventu.re";
const jwks = createRemoteJWKSet(new URL(`${ADMIN_URL}/.well-known/jwks.json`));

export async function verifyUserRoomToken(token: string) {
    // Throws if the signature is invalid, the token is expired, or it was issued by another server.
    const { payload } = await jwtVerify(token, jwks, {
        algorithms: ["RS256"],
        issuer: ADMIN_URL,
        audience: ADMIN_URL,
    });
    if (typeof payload.room !== "string" || !payload.room.startsWith("https://play.workadventu.re/@/org/world/")) {
        throw new Error("This token was not issued for our world");
    }
    return payload;
}
```

:::caution
A valid signature only proves the token was issued by WorkAdventure, for **any** room of **any** world.
Always check that the `room` claim belongs to your world. Also hardcode the URL of the administration panel:
never fetch the keys from the URL found in the `iss` claim of the token you are verifying.
:::

### Get the position of the player
```
WA.player.getPosition(): Promise<Position>
```
The player's current position is available using the `WA.player.getPosition()` function.

`Position` has the following attributes :
* **x (number) :** The coordinate x of the current player's position.
* **y (number) :** The coordinate y of the current player's position.


:::info
You need to wait for the end of the initialization before calling `WA.player.getPosition()`
:::

```typescript
WA.onInit().then(async () => {
    console.log('Position: ', await WA.player.getPosition());
})
```


### Listen to player movement
```
WA.player.onPlayerMove(callback: HasPlayerMovedEventCallback): void;
```
Listens to the movement of the current user and calls the callback. Sends an event when the user stops moving, changes direction and every 200ms when moving in the same direction.

The event has the following attributes :
*   **moving (boolean):**  **true** when the current player is moving, **false** otherwise.
*   **direction (string):** **"right"** | **"left"** | **"down"** | **"top"** the direction where the current player is moving.
*   **x (number):** coordinate X of the current player.
*   **y (number):** coordinate Y of the current player.
*   **oldX (number):** old coordinate X of the current player.
*   **oldY (number):** old coordinate Y of the current player.

**callback:** the function that will be called when the current player is moving. It contains the event.

Example :
```javascript
WA.player.onPlayerMove(console.log);
```

## Player specific variables

Similarly to maps (see [API state related functions](api-state.md)), it is possible to store data **related to a specific player** in a "state". Such data will be stored using the local storage from the user's browser. Any value that is serializable to JSON can be stored.

Each variable can be stored and fetched in a variety of ways.

Here is what defines a player variable.

**Visibility:**

A player variable can be **public** or **private**.
- Public variables are automatically shared to players around you. Players around you can view
these variables using the `RemotePlayer.state` object (you can get a `RemotePlayer` object) using
[`WA.players.list()`](api-players.md).
- Private variables are only accessible by the current user.

**Persistence:**

A player variable can be **persisted** or **transient**

- Persisted variables are stored across sessions. If you refresh your page or come back later,
  a persisted variable can be fetched again. Use "persisted variables" to store valuable values
  (like a score in a game that is played in the long run)
- Transient variables disappear as soon as the connection to the room is lost. So if you
  you refresh your page, if your network connection is lost for a brief amount of time, or
  if you simply close WorkAdventure and come back later, the transient player variable value
  will be lost. Use transient variables for values that are short-lived in inherently tied to
  the game state. For instance, if you are doing a live voting system, you can use a transient
  variable to store the current vote of the player.

**Time to live:**

Persisted variables can have a **Time to live** (TTL):

The TTL (expressed in seconds) is the time after which the stored value will be destroyed.
TTL can be set on persisted variables only. It cannot be set on transient variables.

:::info
Depending on the server you are using, the server might itself decide of a maximum TTL for
player variables. So far, WorkAdventure SAAS version has no maximum TTL set. However, please note you should
probably not use player variables for sensitive / important information.
:::

**Scope:**

A player variable can have 2 scopes:

- **Room** scope: the player variable is attached to a given room.
- **World** scope: the player variable is set for a given world. It is shared with all the rooms
  of this world.

:::info About the notion of "world":

If you are using the SAAS version (online version) of WorkAdventure, you can create your worlds from the admin dashboard
and put your rooms in those worlds.

If you are using the self-hosted version of WorkAdventure (with no custom admin API configured), there is only one world, 
and it is shared by all the rooms defined in the map-storage (i.e. by all URLs starting with `/~/`).

In both cases, any URL starting with `/_/` is **not part** of any world. Trying to set a player variable with a scope
"world" for URLs starting with `/_/` will be the same as setting the scope to "room".
:::




:::info
Player variables can be stored in 2 different places. If the player is logged, the player variables are stored on
the WorkAdventure server. If the player is not logged, the player variables are stored in local storage (so in the
player's browser).
:::


### Setting a player variable
A player variable can be set simply by assigning a value.

Example:
```javascript
WA.player.state.foo = "value"
```

By **default**, variables saved are **persisted** and **private** in the **world** scope.

If you want to set some options, you will need to use the `saveVariable` function:

```typescript
WA.player.state.saveVariable(
    key: string,
    value: unknown,
    options?: {
        public?: boolean;
        persist?: boolean;
        ttl?: number;
        scope?: "world" | "room";
    }
): Promise<void>;
```

For instance, setting a variable shared with other players, that is accessible from any rooms of the current world
with a time to live of one day:

```javascript
WA.player.state.saveVariable("foo", "value", {
  public: true,
  persist: true,
  ttl: 24 * 3600,
  scope: "world",
});
```

### Reading a player variable
A player variable can be read by calling its key from the player's state.

Example:
```javascript
WA.player.state.foo //will retrieve the variable
```

### Listening to a player variable change

You can listen to modifications
of any player variable by using the `WA.player.state.onVariableChange()` method.

```
WA.player.state.onVariableChange(name: string): Observable<unknown>
```

Usage:

```javascript
WA.player.state.onVariableChange('config').subscribe((value) => {
    console.log('Variable "config" changed. New value: ', value);
});
```

The `WA.plaeyr.state.onVariableChange` method returns an [RxJS `Observable` object](https://rxjs.dev/guide/observable). This is
an object on which you can add subscriptions using the `subscribe` method.

### Stopping tracking player variables

If you want to stop tracking a player variable change, the `subscribe` method returns a subscription object with an `unsubscribe` method.

**Example with unsubscription:**

```javascript
const subscription = WA.player.state.onVariableChange('config').subscribe((value) => {
    console.log('Variable "config" changed. New value: ', value);
});
// Later:
subscription.unsubscribe();
```

### Special rules for users connected several times

You can be connected several times with the same user to WorkAdventure (and WorkAdventure
will not complain about it, this is by design).
Open another tab, connect again to WorkAdventure and you will be connected to WorkAdventure
twice with the same user. We will call those users connected several times to WorkAdventure
**brothers**.

Brothers happen to share the same player variables.

Also, if one browser sets a variable to a new value, other brothers can listen to variable
changes using `WA.player.state.onVariableChange`. They will receive the new value
if they are in the same room. So far, there is a limitation preventing brothers from listening to variable changes if
they are in different rooms in the same world.


### Typing player variables

If you are using Typescript, by default, the type of player variables is `unknown`. This is for security purpose, as we don't know
the type of the variable.

Internally, we define two interfaces named `PublicPlayerState` and `PrivatePlayerState` that contains the type of all player variables.
`PublicPlayerState` contains the state of all public variables (variables that are shared with other players) and `PrivatePlayerState` 
contains the state of all private variables (variables that are only accessible by the current player).

The default declaration of `PublicPlayerState` and `PrivatePlayerState` is:

```typescript
interface PublicPlayerState {
    [key: string]: unknown;
}

interface PrivatePlayerState {
  [key: string]: unknown;
}
```

Typescript allows third party module to merge their own types with existing ones. This means that you can define your own
`PublicPlayerState` and `PrivatePlayerState` interfaces in your code, and it will be merged with the default one. You will need to use this syntax in your code:

```typescript
declare module "@workadventure/iframe-api-typings" {
    interface PublicPlayerState {
        someVariable: string,
        anotherVariable: number,
    }

  interface PrivatePlayerState {
    someSecret: string[],
  }
}
```

This will allow you to access `WA.player.state.someVariable` and `WA.player.state.someSecret` with the correct types.

:::caution
Merging your own declaration of `PublicPlayerState` and `PrivatePlayerState` will give you type checking at compile time and autocompletion in your IDE.
However, as it is customary with Typescript, it will not do any actual type checking at runtime. Do not forget that
player variables can be set by any player. This means that even if Typescript tells you that `WA.player.state.someVariable`
is a string, it could be a number at runtime. The only way to be sure of the type of a variable is to check it at runtime
using type guards or a type checking library like Zod.
:::



## Move player to position
```typescript
WA.player.moveTo(x: number, y: number, speed?: number): Promise<{ x: number, y: number, cancelled: boolean }>;
```
:::caution
The parameters `x` and `y` are numbers of **pixels**, not tiles. So make sure to multiply them by 32 if you are counting tiles.
:::

Player will try to find shortest path to the destination point and proceed to move there.
```typescript
// Let's move player to x: 250 y: 250 with speed of 10
WA.player.moveTo(250, 250, 10);
```
You can also chain movement like this:
```typescript
// Player will move to the next point after reaching first one
await WA.player.moveTo(250, 250, 10);
await WA.player.moveTo(500, 0, 10);
```
Or like this:
```typescript
// Player will move to the next point after reaching first one or stop if the movement was cancelled
WA.player.moveTo(250, 250, 10).then((result) => {
    if (!result.cancelled) {
        WA.player.moveTo(500, 0, 10);
    }
});
```
It is possible to get the information about current player's position on stop and if the movement was interrupted
```typescript
// Result will store x and y of Player at the moment of movement's end and information if the movement was interrupted
const result = await WA.player.moveTo(250, 250, 10);
// result: { x: number, y: number, cancelled: boolean }
```

## Teleport player to position

```typescript
WA.player.teleport(x: number, y: number): Promise<void>;
```
:::caution
The parameters `x` and `y` are numbers of **pixels**, not tiles. So make sure to multiply them by 32 if you are counting tiles.
:::

Player will be teleported to the destination point.

```typescript
// Let's teleport player to x: 250 y: 250
WA.player.teleport(250, 250);
```

:::info
You can also teleport a player to a specific entry point of the current map using the `WA.nav.goToRoom` function:
:::

```typescript
// Let's teleport the player to the entry named "my-entry-point"
WA.nav.goToRoom("#my-entry-point");
```

## Set the outline color of the player
```
WA.player.setOutlineColor(red: number, green: number, blue: number): Promise<void>;
WA.player.removeOutlineColor(): Promise<void>;
```

You can display a thin line around your player's name (the "outline").

Use `setOutlineColor` to set the outline and `removeOutlineColor` to remove it.

Colors are expressed in RGB. Each parameter is an integer between 0 and 255.

```typescript
// Let's add a red outline to our player
WA.player.setOutlineColor(255, 0, 0);
```

When you set the outline on your player, other players will see the outline too (the outline color is shared across
browsers automatically).

![](../images/outlines.png)

## Set the status of the player
```
WA.player.setStatus(status: "ONLINE" | "BUSY" | "DO_NOT_DISTURB" | "BACK_IN_A_MOMENT"): void;
```

You can set the availability status of the current player. This status is visible to other players and can be used
to indicate whether you're available for interaction.

Supported statuses:
- **"ONLINE"**: Clears any custom status (default state)
- **"BUSY"**: Indicates the player is busy
- **"DO_NOT_DISTURB"**: Indicates the player does not want to be disturbed
- **"BACK_IN_A_MOMENT"**: Indicates the player is temporarily away

```typescript
// Set the player status to busy
WA.player.setStatus("BUSY");

// Clear the status (back to online)
WA.player.setStatus("ONLINE");

// Indicate you're temporarily away
WA.player.setStatus("BACK_IN_A_MOMENT");
```

## Detecting when the user enters/leaves a meeting

```ts
WA.player.meetings.onJoin(): Subscription<Meeting>

interface Meeting {
  id: string;
  name: string;
  kind: "default" | "proximity" | "meeting" | "listener" | "speaker" | "area";
  participants: RemotePlayerInterface[];
  onParticipantJoin(): Subscription<RemotePlayerInterface>;
  onParticipantLeave(): Subscription<RemotePlayerInterface>;
  onLeave(): Subscription<void>;
  playSound(url: string): Promise<void>;
  startAudioStream(sampleRate: number): Promise<AudioStream>;
  listenToAudioStream(sampleRate: number): Observable<Float32Array>;
}
```

The event is triggered when the user enters a proximity meeting. A proximity meeting can be a bubble, a meeting room,
or another proximity-backed area. The returned `Meeting` object is scoped to that specific meeting.

Example:

```ts
WA.player.meetings.onJoin().subscribe(async (meeting: Meeting) => {
    WA.chat.sendChatMessage("You joined a proximity chat", "System");

    meeting.onLeave().subscribe(async () => {
        WA.chat.sendChatMessage("You left the proximity chat", "System");
    });
});
```

`WA.player.proximityMeeting.onJoin()` and `WA.player.proximityMeeting.onLeave()` are deprecated single-meeting methods.
They only target the default proximity bubble. Use `WA.player.meetings.onJoin()` for new scripts.

## Detecting when a participant enters/leaves the current meeting

```ts
Meeting.onParticipantJoin(): Subscription<RemotePlayerInterface>
Meeting.onParticipantLeave(): Subscription<RemotePlayerInterface>
```

The event is triggered when a user enters or leaves a proximity meeting.

Example:

```ts
WA.player.meetings.onJoin().subscribe((meeting: Meeting) => {
    meeting.onParticipantJoin().subscribe(async (player: RemotePlayerInterface) => {
        WA.chat.sendChatMessage("A participant joined the proximity chat", { scope: 'local', author: 'System' });
    });

    meeting.onParticipantLeave().subscribe(async (player: RemotePlayerInterface) => {
        WA.chat.sendChatMessage("A participant left the proximity chat", { scope: 'local', author: 'System' });
    });
});
```

`WA.player.proximityMeeting.onParticipantJoin()` and `WA.player.proximityMeeting.onParticipantLeave()` are deprecated
single-meeting methods. They only target the default proximity bubble.

## Playing a sound to players in the same meeting

:::warning
This feature is experimental. The signature of the function might change in the future.
:::

```ts
Meeting.playSound(url: string): Promise<void>
```

The `playSound` function plays a sound to all the players in the same bubble.
The sound will appear to come from the microphone of the player who called the function.

Example:

```ts
WA.player.meetings.onJoin().subscribe(async (meeting: Meeting) => {
    await meeting.playSound("https://example.com/my_sound.mp3");
});
```

The method returns a promise that resolves when the sound has been played.

`WA.player.proximityMeeting.playSound()` is deprecated and only targets the default proximity bubble.

## Streaming sound to players in the same meeting

:::warning
This feature is experimental. The signature of the function might change in the future.
:::

You can send a stream of audio to all the players in the same bubble. A typical use case for this feature is to create a 
voice chat in WorkAdventure. The sound can be generated on a server and streamed to the players in the bubble.

```ts
Meeting.startAudioStream(sampleRate: number): Promise<AudioStream>;

interface AudioStream {
  appendAudioData(data: Float32Array): Promise<void>;
  resetAudioBuffer(): Promise<void>;
  close(): Promise<void>;
}
```

The `startAudioStream` function starts an audio stream to all the players in the same bubble. The `sampleRate` parameter 
is the sample rate of the audio stream. For a 24kHz audio stream, you would use `24000`.

The function returns an `AudioStream` object that you can use to send audio data to the players.

The `appendAudioData` function sends a chunk of audio data to the players. The `data` parameter is an array of
float32 values representing the raw uncompressed audio data.

You can send multiple chunks of audio data in a row. If you are sending chunks of audio data faster than the sound
is played, the audio stream will buffer the data and play it at the correct speed.

`appendAudioData` returns a promise. The promise resolves only when the sound was actually dispatched/played in the bubble.

:::warning
You don't want to put an `await` in front of your call to `appendAudioData`. Indeed, you should put as much as possible
in the audio buffer. If you wait for the sound to be played before emitting the next bit of sound, the sound will stutter.  
:::


:::note
Please note the sound is played to all the players in the bubble except the player who called the function.
:::

If you sent too much data and want to stop the audio stream, you can call the `resetAudioBuffer` function. This will
empty the audio buffer and stop the audio stream. When you do so, any promise returned by `appendAudioData` that
match a chunk of audio data that was not played yet will be rejected.

Finally, when you are done with the audio stream, you can call the `close` function. This will stop the audio stream
and free the resources.

Example:

The following example generates a 10 seconds long sine wave at 440Hz and sends it to the players in the bubble for 5 seconds.
Then it stops the stream and waits for 5 seconds before closing the stream.

```ts
const sampleRate = 24000;

const audioStream = await meeting.startAudioStream(sampleRate);

// Generate a sine wave
    
const frequency = 440;
const amplitude = 0.5;
const duration = 10;
const numSamples = duration * sampleRate;
const samples = new Float32Array(numSamples);
for (let i = 0; i < numSamples; i++) {
    samples[i] = amplitude * Math.sin(2 * Math.PI * frequency * i / sampleRate);
}

audioStream.appendAudioData(samples);

// Wait for 5 seconds
await new Promise((resolve) => setTimeout(resolve, 5000));

// Stop the stream.
audioStream.resetAudioBuffer();

// Wait for 5 seconds
await new Promise((resolve) => setTimeout(resolve, 5000));

// Close the stream.
await audioStream.close();
```

`WA.player.proximityMeeting.startAudioStream()` is deprecated and only targets the default proximity bubble.

## Listening to the microphone of the players in the same meeting

:::warning
This feature is experimental. The signature of the function might change in the future.
:::

```ts
Meeting.listenToAudioStream(sampleRate: number): Observable<Float32Array>
```

The `listenToAudioStream` function listens to the microphone of all the players in the same bubble. The `sampleRate` parameter
is the sample rate of the audio stream. For a 24kHz audio stream, you would use `24000`.

The function returns an RxJS `Observable` object that you can use to listen to the audio data. The observable is called
every few milliseconds with a chunk of audio data.

The voice of all players in the bubble is merged in a single mono stream.

Audio data is sent as an array of float32 values representing the raw uncompressed audio data.

Example:

```ts
const sampleRate = 24000;

const subscription = meeting.listenToAudioStream(sampleRate).subscribe((data: Float32Array) => {
    // Process the audio data
    console.log(data);
});

// When you are done listening to the audio stream, you can unsubscribe from the observable.
subscription.unsubscribe();
```

`WA.player.proximityMeeting.listenToAudioStream()` is deprecated and only targets the default proximity bubble.

## Asking users to follow you

:::warning
This feature is experimental. The signature of the function might change in the future.
:::

```ts
WA.player.proximityMeeting.followMe(): Promise<void>
```

The `followMe` function asks all the players in the same bubble to follow the player who called the function.
Unlike the "follow" button in the UI, all the players in the bubble will be forced to follow the player who called the function.
They can still stop following the player by clicking on the "stop following" button in the UI.



## Stop leading users

:::warning
This feature is experimental. The signature of the function might change in the future.
:::

```ts
WA.player.proximityMeeting.stopLeading(): Promise<void>
```

This function is the opposite of `followMe`. It ends the "follow" state for all the players in the bubble.

Example:

```ts
// Start leading the users
await WA.player.proximityMeeting.followMe();
// Move everybody to (250, 250)
await WA.player.moveTo(250, 250);
// Stop leading the users
await WA.player.proximityMeeting.stopLeading();
```

## Tracking who is following you

:::warning
This feature is experimental. The signature of the function might change in the future.
:::

```ts
WA.player.proximityMeeting.onFollowed(): Subscription<RemotePlayerInterface>
WA.player.proximityMeeting.onUnfollowed(): Subscription<RemotePlayerInterface>
```

You can be notified when a player starts following you or stops following you.

Example:

```ts
WA.player.proximityMeeting.onFollowed().subscribe(async (player: RemotePlayerInterface) => {
    WA.chat.sendChatMessage(`${player.name} is now following you`, { scope: 'local', author: 'System' });
});

WA.player.proximityMeeting.onUnfollowed().subscribe(async (player: RemotePlayerInterface) => {
    WA.chat.sendChatMessage(`${player.name} stopped following you`, { scope: 'local', author: 'System' });
});
```
