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
EInputKeyvalue (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 aMsgpack.Genattribute 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 theVoidvariant) and return its result - To change what the original receives, call
re.Hooks.CallOriginalWith<TOut, THandler>(…)(orCallOriginalWithVoid(…)), passing the handler's record with the values you want overridden. Fields left asnullare 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:

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( |
ClientConnected |
A client finished joining | ClientConnected( |
ClientReadyForObserver |
A joined client needs its in-game entity | ClientReadyForObserver( |
ClientDisconnecting |
A client is leaving | ClientDisconnecting( |
UgcBundleStartedUgcBundleStopping |
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( |
EntityDetaching |
A game entity is about to detach from the world | EntityDetaching( |
EntityDisposed |
A game entity was disposed and removed from the client realm | EntityDisposed( |
UgcBundleStartedUgcBundleStopping |
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 anOnNethandler and gets the sender'sClientIdalongside the payload - Server to client: the server sends with
re.Events.EmitNetBroadcast(every client) orre.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 | GetPublicKVSetPublicKV |
GetPublicKVSetPublicKV |
| Public authoritative | everyone | server | GetPublicAuthoritativeKV |
GetPublicAuthoritativeKVSetPublicAuthoritativeKV |
| Private | owner and server | owner and server | GetPrivateKVSetPrivateKV |
GetPrivateKVSetPrivateKV |
| Private authoritative | owner and server | server | GetPrivateAuthoritativeKV |
GetPrivateAuthoritativeKVSetPrivateAuthoritativeKV |
| Server only | server | server | not available | GetServerKVSetServerKV |
| Client only | owner | owner | GetClientKVSetClientKV |
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:
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}!"
);
}
}
}
);
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
);
}
}
);
[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 runawait Future.Sleep(ms)resumes after the given delay in millisecondsFuture.Spawn(fn)starts a fire-and-forget coroutine- Do not block! A handler that spins without returning stalls script execution.
Use the
Futurehelpers 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