Skip to content

Script API

Your bundle code runs inside an isolated sandbox hosted within either the server or game client. Whichever realm executes your component changes what kind of APIs you have access to. Both realms share a subset of APIs and generic types, but the majority is realm specific.

Scripting Docs

See our specialized scripting docs at scripting.replay.re for all the APIs you can use in either realm. Custom extensions that are not part of the base game are marked with flairs

Nothing else is reachable from bundle code. The sandbox can only access what these APIs expose, plus the IO channels described in Bundle IO.

Registration happens during App.Start

Commands, hooks and event handlers are all callbacks. They tell the script host "call my code when X happens". The script host only accepts registrations during the initial execution of your bundle, which is the single App.Start() call it makes when activating you. Once that call returns, the script host no longer grants access to its registration state, and code that tries to register from inside a later callback (a command body, an event handler) cannot.

Info

Register all callbacks in App.Start(). Do not defer the registration. The things you register are then active for the lifetime of the bundle and are cleaned up automatically when the bundle stops

Console commands

re.Con.RegisterCommand makes a command available in the host's console:

re.Con.RegisterCommand(
    "setmode",
    (string[] args) =>
    {
        // args are the space-split tokens after the command name
        if (args.Length == 0)
        {
            Console.WriteLine("Usage: setmode <mode_id>");
            return;
        }
        // ...
    }
);
  • Command names are unique per realm, across all bundles. Registering a name that is already taken fails and is logged
  • A server-realm command runs when typed into the server console. A client-realm command runs when typed into the game console, and can also be attached to a key (below)

Keybinds (client realm)

On the client, re.Con.RegisterCommandWithKeyBind registers the command and binds a key to it in one step:

re.Con.RegisterCommandWithKeyBind(
    "open-chat-window",
    (string[] args) =>
    {
        // open the chat UI, capture input, ...
    },
    EInputKey.IK_Enter,
    "Open Chat Window"
);
  • The key parameter is an EInputKey value (the constants are documented in the Input Keys reference)
  • The binding shows up in the player's keybind settings under your bundle, so players can rebind it. The description string is what they see
  • Binding a key that is already bound fails, so does registering a command name that is taken

Hooks (client realm)

Hooks let client code intercept a game function. Whenever the game calls that function, your handler runs instead, and it decides whether the original call happens.

re.Hooks.PlaceVoid(
    "PlayerPuppet",
    "OnDetach",
    (PlayerPuppet_OnDetach x) =>
    {
        // x carries the function's parameters (here x.self)
        re.Hooks.CallOriginalVoid(); // let the original run
    }
);

[Msgpack.Gen]
public readonly partial record struct PlayerPuppet_OnDetach(
    PlayerPuppet self
);
  • The class and function names identify a specific game function. We have an automatic generator for creating hook templates in the client API reference. Press the 'Hook' button behind a function you would like to intercept
  • The handler's argument type (here PlayerPuppet_OnDetach) is the serializer record for that function and carries its parameters. It carries a Msgpack.Gen attribute which automatically creates the serialize and deserialize stubs for the parameter range
  • To replace a result, return it directly and skip the original call. To pass through, call re.Hooks.CallOriginal<TOut>() (or the Void variant) and return its result
  • To change what the original receives, call re.Hooks.CallOriginalWith<TOut, THandler>(…) (or CallOriginalWithVoid(…)), passing the handler's record with the values you want overridden. Fields left as null are passed through unchanged, so only overriding fields have to be set explicitly
  • Hooks support hooking script implemented or native game functions

Modifying parameters passed to game functions is done via CallOriginalWith<TOut, THandler>(...) / CallOriginalWithVoid(…). Fields left as null are passed through unchanged, so only overriding fields have to be set explicitly.

Example

A convenient way to only change one parameter is to use the with expression. This example will replace certain button hints with a "Replaced" text:

re.Hooks.PlaceVoid(
    "ButtonHints",
    "AddButtonHint;EInputKeyscript_ref<String>",
    (ButtonHints_AddButtonHint x) =>
    {
        Console.WriteLine($"Value before replacing:  {x.label}");
        re.Hooks.CallOriginalWithVoid(x with { label = "Replaced" });
    }
);

re.Hooks.PlaceVoid(
    "ButtonHints",
    "AddButtonHint;CNamescript_ref<String>",
    (ButtonHints_AddButtonHint2 x) =>
    {
        Console.WriteLine($"Value before replacing  {x.label}");
        re.Hooks.CallOriginalWithVoid(x with { label = "Replaced" });
    }
);

[Msgpack.Gen]
public readonly partial record struct ButtonHints_AddButtonHint(
    ButtonHints self,
    EInputKey icon,
    string label
);

[Msgpack.Gen]
public readonly partial record struct ButtonHints_AddButtonHint2(
    ButtonHints self,
    CName action,
    string label
);

Result: replaced-button-hints

Events

Events come in two variants. Local events stay inside one realm, net events cross between a client realm and the server realm.

Local events

re.Events.On(name, handler) subscribes to a named event in the current realm. re.Events.Emit(name, payload) raises one. Every bundle in the realm with a matching handler receives it. The payload type is declared by you, except for the system events the host itself raises, which carry pre-defined payload records.

The server realm raises these system events:

Event Raised when Payload
ClientConnecting A client requests to join. Decide by accepting or rejecting the connection ClientConnecting(
  connection,
  identity,
  username,
  userid
)
ClientConnected A client finished joining ClientConnected(
  id,
  identity
)
ClientReadyForObserver A joined client needs its in-game entity ClientReadyForObserver(
  id
)
ClientDisconnecting A client is leaving ClientDisconnecting(
  id
)
UgcBundleStarted
UgcBundleStopping
Any bundle on the realm started or stopped UgcBundleStarted(name)
UgcBundleStopping(name)

The client realm raises these system events:

Event Raised when Payload
EntityCreating A game entity starts spawning in the client realm (its spawn request was queued) EntityCreating(gameid)
EntityAttached A game entity finished attaching to the world EntityAttached(
  gameid,
  netid
)
EntityDetaching A game entity is about to detach from the world EntityDetaching(
  gameid,
  netid
)
EntityDisposed A game entity was disposed and removed from the client realm EntityDisposed(
  gameid,
  netid
)
UgcBundleStarted
UgcBundleStopping
Any bundle on the realm started or stopped UgcBundleStarted(name)
UgcBundleStopping(name)

Payload record parameters

System event payloads are records in the re namespace (the prefix is omitted in the tables above). Their parameters mean:

Parameter Meaning
connection Handle of the pending connection (uint). Approve it with re.GameClientRegistry.AcceptConnection. Without approval the connection attempt times out and is dropped (This does not apply if no bundle handles the event)
identity Identity token presented by the connecting client (re.ClientIdentity, 32 bytes)
username, userid Display name and user id carried with the connection (string, ulong)
id The client's id in the session (re.ClientId). Use it to target net events and registry calls such as DisconnectClient
gameid The entity's id in the client sided game world (ent.EntityID). These are client specific and not unique!
netid The unique entity's networked id (re.EntityId). Invalid when the entity is not networked
name The name of the bundle that started or stopped (string)

The server host applies defaults when nothing handles an event: an unhandled ClientConnecting accepts the connection, and an unhandled ClientReadyForObserver gives the client a default humanoid entity. Your gamemode overrides those events to implement its own connection rules and spawning. How to do that safely is the topic of The Networking Model.

Net events

re.Events.OnNet / re.Events.EmitNet send messages between a client realm and the server realm. Event names are arbitrary strings. The sending and receiving side of your bundle simply have to agree on them.

  • Client to server: The client calls re.Events.EmitNet(name, payload). The server receives it in an OnNet handler and gets the sender's ClientId alongside the payload
  • Server to client: the server sends with re.Events.EmitNetBroadcast (every client) or re.Events.EmitNetTargeted (one client or a list). Clients receive the payload without a sender id

Warning

Any data coming from clients is claimed data, not verified game state. Validate it before acting on it. See the networking model for what this means in practice

The only exception is the ClientId received in a server OnNet handler

It is guranteed that this id belongs to the message author

Scripting state (key-value storage)

re.ecs.ScriptingStateComponent attaches arbitrary key-value pairs to networked entities. Use it to replicate custom data like per-entity counters, team membership, per-player progress or similar.

Keys are strings. The wrappers serialize the value to MessagePack, so any type that implements Msgpack.ISerde<T> can be stored, which includes every record declared with [Msgpack.Gen]. The type is not stored with the value, only the raw bytes are.

Every entry lives in one of the stores below. The store decides who may read the pair and who may write it:

Store Readable by Writable by Client realm Server realm
Public everyone everyone GetPublicKV
SetPublicKV
GetPublicKV
SetPublicKV
Public authoritative everyone server GetPublicAuthoritativeKV GetPublicAuthoritativeKV
SetPublicAuthoritativeKV
Private owner and server owner and server GetPrivateKV
SetPrivateKV
GetPrivateKV
SetPrivateKV
Private authoritative owner and server server GetPrivateAuthoritativeKV GetPrivateAuthoritativeKV
SetPrivateAuthoritativeKV
Server only server server not available GetServerKV
SetServerKV
Client only owner owner GetClientKV
SetClientKV
not available

"Owner" is the client the entity is assigned to. "Authoritative" stores only contain entries the server owns. Clients can read them but not write or create.

All methods take the entity id and the key: GetPublicKV<T>(id, "state"), SetPublicKV(id, "state", value).

How entries replicate

A value written in the client realm into a public or private store is sent to the server and replicated to all clients that may read it. If the store is private, the value will not be sent to other clients.

Same goes for values written in the server realm, with the special case of "Authoritative" stores. These can only be written by the server. Clients and the Server itself can trust that entries were produced on the server and not modified by clients. This is useful for distributing critical entity metadata without having to build Event handler/sender pairs for everything.

Server-only values never leave the server, client-only values never leave the client.

A write is applied immediately, but replication happens on the network tick.

Check Exists, then check for null

Queryre.ecs.ScriptingStateComponent.Exists(id) first

Every getter returns T? and yields null when the key has no value or when the stored bytes do not deserialize into the T you asked for

Always handle null with a default of your own failure mode

[Msgpack.Gen]
public partial record struct PlayerState(ulong kills, string team);

re.Con.RegisterCommand(
    "give-kill",
    () =>
    {
        foreach (var id in re.EntitySystem.GetEntities())
        {
            if (!re.ecs.ScriptingStateComponent.Exists(id))
            {
                continue;
            }

            var state = re.ecs.ScriptingStateComponent.GetPublicKV<PlayerState>(id, "mybundle::state")
                ?? new PlayerState(0, "no-team");

            state.kills += 1;

            re.ecs.ScriptingStateComponent.SetPublicKV(id, "mybundle::state", state);
        }
    }
);

A few things to keep in mind:

  • Key names are unique per store per entity and shared across bundles. Use unique names and ideally prefix them with your bundle or gamemode name to avoid collisions
  • Values of 1 MiB or more are dropped by the receiving side, and a replicated store stops accepting keys once it holds about 1024 of them. Such writes are ignored to prevent denial of service

The exact wrapper list per realm is in the scripting API reference, under client and server.

Example

This example replicates an arbitrary string an and incrementing counter on every networked entity:

cl/App.cs
re.Con.RegisterCommand(
    "getKV",
    (string[] args) =>
    {
        foreach (var id in re.EntitySystem.GetEntities())
        {
            if (!re.ecs.ScriptingStateComponent.Exists(id))
            {
                continue;
            }

            var entry =
                re.ecs.ScriptingStateComponent.GetPrivateAuthoritativeKV<ExampleKvData>(
                    id,
                    "kv-private-set-from-server"
                );
            if (entry != null)
            {
                Console.WriteLine(
                    $"re.ecs.ScriptingStateComponent.GetPrivateAuthoritativeKV {id} -> {entry?.counter} {entry?.someString}!"
                );
            }
        }
    }
);
sv/App.cs
re.Con.RegisterCommand(
    "setKV",
    (string[] args) =>
    {
        foreach (var id in re.EntitySystem.GetEntities())
        {
            if (!re.ecs.ScriptingStateComponent.Exists(id))
            {
                continue;
            }

            var entry =
                re.ecs.ScriptingStateComponent.GetPrivateAuthoritativeKV<ExampleKvData>(
                    id,
                    "kv-private-set-from-server"
                ) ?? new ExampleKvData(0, "initial-data");
            entry.counter += 1;
            entry.someString = args[0];

            re.ecs.ScriptingStateComponent.SetPrivateAuthoritativeKV(
                id,
                "kv-private-set-from-server",
                entry
            );
        }
    }
);
sh/Messages.cs
[Msgpack.Gen]
public partial record struct ExampleKvData(ulong counter, string someString);

Scheduling

Your handlers run synchronously while the script host executes the call that reached them. To pause or space out work, the framework provides a Future type with async/await support (handlers may be async):

re.Events.OnNet(
    "some-event",
    async (clientId) =>
    {
        await Future.Sleep(2000); // resume roughly 2 seconds later
        // ...
    }
);
  • await Future.Yield() pauses until the next scheduled run
  • await Future.Sleep(ms) resumes after the given delay in milliseconds
  • Future.Spawn(fn) starts a fire-and-forget coroutine
  • Do not block! A handler that spins without returning stalls script execution. Use the Future helpers instead of busy loops

Danger

An infinite loop without a proper await Future.Yield() or await Future.Sleep(ms) will cause a deadlock in the script host

To ensure best performance, make sure to use the longest sleep delays possible for the work you are planning to do