Scripts
@rpgjs/script lets a game, an editor or an AI agent add gameplay as text without giving it the keys of the game. A script is
written in a subset of JavaScript and run by its own interpreter: it is never evaluated as JavaScript, so it can only use what
the game exposes, and it is stopped when it uses too much.
A script does not call the game. It listens to the signals of the game (a player levels up, an event is activated, a zone is
entered…) and reacts, with the names of the API of RPGJS: $player.showText, $player.addItem, $map.createDynamicEvent…
Where it runs
The server owns the gameplay state, so scripts run on the server: in an MMORPG (Node or Cloudflare) and in a standalone game, where the server part runs next to the client. Scripts are never sent to players. The interpreter has no dependency on the engine (@rpgjs/script/core), so it also runs in a Worker or a browser, for example to check a script before saving it.
A script runs in a map or in a gameplay room that is not a map (see Custom Gameplay Rooms).
$map and the events only exist in a map; $room says where the script is.
Register it
provideScripts is a module: it adds to the modules of the game, it does not replace provideServerModules.
.ts module rather than importing a file with ?raw: the server module is also loaded by vite.config.ts
(rpgjs({ server })), which does not resolve ?raw.
A script is { id, source }, or { id, tree } when the tree was already computed from the text (an editor does it once, on its
server, with parseScript from @rpgjs/script/parse). version is optional and names the version.
A script is refused when it does not parse, uses a name that does not exist, or listens to a signal the game does not have. The
reason is logged and kept in service.errors, and the other scripts run.
playground/games/scripts is a small town with a chest, a shop whose stock is shared by the players, a trap, and a zone at the bottom
that shakes the screen, all written as scripts (src/scripts; the zone is made by a script with $map.createShape). Run it with pnpm --dir playground/games/scripts dev (add RPG_TYPE=mmorpg in front for the MMORPG mode),
walk with the arrow keys, press the action key next to the chest or the shop, and step on the red block and walk to the bottom of the town.
The language
A subset of JavaScript:const/let, functions and arrow functions, if/for/while, template texts, lists and objects with the
usual methods (map, filter, find, reduce, includes, join…), ?? and ?..
Not available: eval, Function, classes, import, new, timers, the prototype, optional calls (a.b?.()). A name that does not
exist is refused when the script is installed, with the line and what is available.
Data that comes from the game is read-only: copy it ({ ...x }, [...x]) before changing the copy. A function of the API that
returns nothing (showText, addItem…) cannot be used as a value.
A script has a budget, so that none can hurt the game: steps, 100 iterations per loop, texts of 10 000 characters, lists and objects
of 1 000 entries, call depth 20, $state of 10 000 characters. A script that goes over is stopped, reported in the console, and the
game goes on.
Signals
A listener is writtenon(signal, handler) or on(signal, filter, handler). The handler receives the payload e, which it can
read and cannot change. Listeners observe: none can refuse or modify what happened. They run in the order the scripts were
installed, and a listener that fails does not stop the next one.
The script acts for the player of the signal (
$player) and, for the event signals, for that event ($event). The signals of
the player fire in every kind of room, a map or not; event.action and event.touch only exist in a map, since events do. The zones
of player.inShape are the ones of the game and the ones scripts make.
The filter
The filter is plain data, never computed, so it is known before anything runs. All of its entries must hold.where: conditions on the payload, by dotted path ("shape.name"). A value alone means equal. Operators:eq,ne,gt,gte,lt,lte,in: [...],has: value(the field is a list that contains it) andexists: true | false. A path that is not in the payload of the signal, or an operator that does not fit its type (hason a number), is refused when the script is installed.when: conditions on the player, a list.
Comparisons are
eq, ne, gt, gte, lt, lte.
Add more signals
The mapping from the hooks of RPGJS to signals is data, so it can be extended. A binding says which hook of which module scope (player, event, map, room, engine, projectiles) to listen to, and turns the arguments of the hook into the payload of the
signal and the context it happened in:
describe adds the signal to the catalog, so a script that listens to it has its filter checked against the payload. Several
bindings may listen to the same hook. Return nothing from emit to emit nothing. The context is the player; the room, the map and
the event are the ones of the player unless you give them. DEFAULT_SIGNAL_BINDINGS lists the ones of the game.
A plugin that has its own events adds its signals with the signals option of ScriptService and sends them with service.emit
(see below).
What a script can use
Every name is the one of the API of RPGJS.The player: $player
The player of the signal, with the names of RpgPlayer.
RPGJS has variables, not switches: a flag is a variable (
$player.setVariable("tournament", true)). A private switch of an event is
a variable of the player named after the event ($event.id + "_opened"), as the chest of the playground does.
Where the script is: $room, $event
$room:kind("map"for a map, otherwise the kind of the gameplay room) andidof the room the script runs in.$event:id,name,x,yof the event the script runs for (empty and 0 when there is none).
The map: $map
The map the script runs in, with the names of RpgMap. In a room that is not a map it does not exist: using it is an error that says
so, and $room.kind === "map" tells before.
Variables of the map:
getVariable(key), setVariable(key, value), hasVariable(key), removeVariable(key). They are shared by
the players of the map, separate from the variables of a player, and hold the same plain values.
Zones (shapes)
Weather and lighting. They are plain data of at most 4 000 characters, with the fields RPGJS knows (see the guides of the weather
and the lighting).
setWeather needs an effect or a preset; patchWeather changes some fields and merges params.
Sounds and effects, for the players of the map
Limits. A script makes 50 events and 50 zones on a map at most, an id or a name that is already on the map is refused, a
number outside its range is refused with the range, and a description with a key RPGJS does not take is refused with the keys that
are accepted. Only what a script made can be removed by a script.
Shared properties and state
A script declares what the players of a room share, and every player reads and changes the same value:map.setSync, under the prefix script_ (script_stock), so a script cannot reach another
property of the map. In a gameplay room that is not a map they live in the state of the room, under the same prefix: RPGJS
synchronizes that state with the clients, and the state of the room then holds the script_* keys next to its own. A lobby has
neither, and a script that uses shared properties there gets an error that says so.
client: true sends the value to the clients. $shared.take(name, amount) tests and decreases in one step: two players cannot both
take the last one.
$state is data kept by the script, in memory: assign a whole value, $state.crafted = { ...$state.crafted, potion: 1 }.
Helpers
Install scripts while the game runs
inject(ScriptService) gives the service back. service.listeners("player.dead") says who listens, in the order they run, with
their filter.
A plugin that has its own events adds its signals, so that scripts can listen to them and the compiler can check their payload:
Write and test scripts without the game
@rpgjs/script/testing runs a script in an in-memory world:
start by default; world: { room: { id: "battle-1", kind: "battle" } } makes it a room
that is not a map, events and shapes put the events and the zones of the game on the map.
expect keys are returns, error, effects, effectsInclude, effectsExclude, texts, notifications, and paths
player.gold, player.items.<id>, shared.<name>, state.<key>, vars.<name>; a value is exact or { min, max }.
The effects are named after the call that made them: showText, showNotification, showChoices, changeMap, addItem,
removeItem, setVariable, removeVariable, gold (with the new value), wait, sync (a shared property), state, and the
ones of the map: createDynamicEvent, removeEvent, createShape, removeShape, setMapVariable, removeMapVariable,
setWeather, patchWeather, clearWeather, setLighting, patchLighting, clearLighting, setDay, setNight,
transitionLighting, playSound, stopSound, showAnimation, showComponentAnimation, shakeMap.
Run your own world
The behavior of every name is written once over a port,ScriptWorld, that has the shape of the API of RPGJS (getItem, addItem,
getVariable, showText, showChoices, and a MapPort for the map). createRpgjsWorld({ player, map?, room?, event?, shared?, hook? })
implements it with the API of RPGJS, and SimWorld in memory. A host with its own vocabulary, such as a block editor, implements it
with its own operations, and gets the same behavior.