resourcepack.ai
DOCS

Developers

Plugin API

RP Engine has a Java API, so your own Spigot or Paper plugin can use what a server's pack holds: give a custom item, place a model, play an emote, park a vehicle, spawn a custom mob, show a HUD, open a dialog with your own values on it, and cancel any of those when your rules say no.

Every snippet is shown in Java and Kotlin; pick one and the rest of the docs follows your choice. This page matches RP Engine 0.2.59.

Setting up#

RP Engine is not published to a Maven repository. Download RPEngine.jar from resourcepack.ai/rp-engine, put it in your project's libs/ folder and compile against it. Use compileOnly: the server already has the plugin, and a shaded second copy breaks class loading.

dependencies {
    compileOnly 'org.spigotmc:spigot-api:1.21.4-R0.1-SNAPSHOT'
    compileOnly files('libs/RPEngine.jar')
}

RP Engine runs on Java 17 and Minecraft 1.19.4 or newer, so target Java 17 if you want your plugin to load everywhere the engine does.

In your plugin.yml, tell the server to load RP Engine first:

depend: [RPEngine]       # your plugin can't run without it
# or
softdepend: [RPEngine]   # optional: check it is there before using it

Getting the API#

Everything hangs off the plugin instance. Fetch it in onEnable:

if (!(Bukkit.getPluginManager().getPlugin("RPEngine") instanceof RPEnginePlugin engine)) {
    getLogger().info("RP Engine isn't installed");   // only possible with softdepend
    return;
}
 
engine.items();         // custom items
engine.models();        // models, and the ones standing in your worlds
engine.emotes();        // emotes and movement sets
engine.vehicles();      // vehicles, and the ones standing in your worlds
engine.shaderObjects(); // 3D shader objects: place them and drive them
engine.armor();         // 3D armour sets, and putting them on people
engine.sounds();        // custom sounds
engine.music();         // background music: playlists, one per player
engine.icons();         // icons, and putting one into text
engine.textEffects();   // Text Effects, and writing text in one
engine.vfx();           // VFX: play one where somebody stands
engine.mobs();          // custom mobs: spawn one, animate one, give an egg
engine.skills();        // mob skills: add a mechanic, condition or targeter; cast one
engine.overlays();      // HUD overlays: show one, hide one, feed it values
engine.dialogs();       // dialogs: open one, feed it values, read player settings
engine.registry();      // everything this server holds, by id
engine.registration();  // adding content of your own

What is API and what is not

Everything in ai.resourcepack.engine.api (and api.event) is supported. Nothing in ai.resourcepack.engine.core is, and it can change in any release, including a patch.

IDs#

Everything has one namespace:path ID, an ordinary Minecraft resource location. parse returns empty rather than throwing, so a bad ID in somebody's config becomes a message, not a stack trace.

ContentId ruby = ContentId.parse("mypack:ruby").orElseThrow();

Store IDs, never what they resolve to. An icon's codepoint moves when content changes and an item's model is derived from its ID. The ID is the only stable reference.

Items#

Optional<ItemStack> stack = engine.items().create(ruby);
Optional<ContentId> what  = engine.items().idOf(hand);
boolean isRuby            = engine.items().is(hand, ruby);

Identity lives in the stack's persistent data, not its name or model. An item renamed in an anvil is still itself, and a vanilla diamond somebody named "Ruby" is not one. create(id, amount) makes a stack of several.

To make a stack of your own wear a pack's art without becoming that item, call wearModel(stack, modelId). Don't set the item model yourself: below 1.21.4 the model is a number only the engine knows. create and wearModel are main thread only; ids() and info() are safe from any thread.

Models#

engine.models().place(location, "golem", PlaceOptions.defaults());  // a Studio model
engine.models().place(location, tree, PlaceOptions.defaults().placer(player)); // an item's place: block
engine.models().itemFor("golem", ItemOptions.defaults());
engine.models().near(location, 16);   // what is standing around here
engine.models().at(entity);           // is this entity part of a model?
engine.models().at(entity).ifPresent(model -> model.play("wave", true)); // play on a placed model

There are two ways to place:

  • By model id (a String): just the model. PlaceOptions sets a starting animation, a scale and a placer.
  • By item ContentId: exactly what a player placing that item gets (hitbox, seat, light, storage, states, animated rig, the item's place actions). With a placer set, ModelPlaceEvent fires first, with stack() null. It returns empty when the item has no place: block, the block is occupied, or a listener cancelled it.

A Placement is a handle on a model standing in a world:

MethodWhat it does
modelId(), location(), hitbox()What it is and where; hitbox() is its Interaction entity
animations(), playing()Its animation names, and the one running
play(name)Plays an animation. Asking again for a one-shot that is mid-play is ignored
play(name, true)Starts it over even if it is already running
stop(), seek(seconds), playhead()Stop, drive its clock yourself, read how far in it is
data()Persistent data saved with the model, for your own keys (Placement.key(plugin, name))
remove(dropItem), isValid()Break it, and check it still exists

play returns false when the model has no animation by that name, the model has no moving parts (one that places as a single still display can't animate), it has been broken, or that one-shot is already playing (use play(name, true) to restart it). It never throws.

near(location, radius) and in(chunk) find placements; there is no "every model on the server" call. isModel(entity) says whether an entity belongs to any model.

Models on entities that aren't ours#

bind(entity, model) (optionally with a scale) puts a model on an NPC or a boss, unbind takes it off, modelOn(entity) says which one it wears, and animate(entity, name) / stopAnimating(entity) play it.

animate returns false when the entity wears nothing of ours, or its model has no animation by that name. It is only for a bound model: a model standing in the world, including the hitbox a right-click hands you, also answers false. For one of those, use at(entity) and play on the placement, as above.

What the pack says about a model#

What a pack's place: block said is a read-only ModelInfo: facing, scale, seat and light, plus storage() (a StorageSpec: type, rows, title, sounds), jukebox(), states() with nextState, lightIn and solidIn, grow(), and variation() (the random turn and size it is placed with). The engine handles the clicks itself; cancel ModelInteractEvent to stop one. Growing fires neither ModelBreakEvent nor ModelPlaceEvent. See Working furniture.

Emotes#

engine.emotes().play(player, "wave");              // solo
engine.emotes().play(lead, "hug", List.of(other)); // with a cast
engine.emotes().canPerform(player);
engine.emotes().isEmoting(player.getUniqueId());
engine.emotes().stop(player);

A cast is moved, so ask them first

An emote with a cast teleports the people it names. The engine's own /emote sends an invitation first; play does not, so asking is your job.

EmoteResult carries a typed reason rather than a sentence, so you write the message in your own words and language.

Putting a rig on somebody yourself#

engine.emotes().wear(player, "rowing");   // again to swap, null to take off
engine.emotes().face(player, 90f);        // point the body; null follows their look
engine.emotes().seek(player, seconds);    // pose the rig at this moment of its emote
engine.emotes().seekProp(player, "skate_right", clock); // drive one carried prop's clock

wear is a movement set where you, not the player's legs, decide what the rig wears. Moving doesn't end it, and calling it again swaps without restarting. Emotes.BUILT_IN_SITTING and BUILT_IN_STANDING work on any pack.

  • face is for a passenger whose body belongs to a seat. Call it every tick with the seat's direction. It only affects a rig you put on with wear.
  • seek, called every tick with a placement's playhead(), keeps a rig in step with a model's animation (a paddler's arms on a paddle).
  • seekProp drives a carried prop by your own clock, such as a wheel that turns with distance travelled. It survives a swap to another emote carrying the same prop.
  • anchor(player, feet) pins the rig to a point you move every tick (a seat you move yourself), so the two arrive together; null hands it back.
  • lean(player, pitch, roll) tips the rig in the wearer's own frame, such as a rider leaning into a corner. It holds until you set it again.
  • wear(player, id, under) layers the emote over one of the built-in stances, for an emote that animates only some bones.

Carrying a rig on a seat of your own#

A worn rig follows its wearer by being teleported every tick, which the client smooths out, so on anything fast it trails behind. Mount the rig's displays on your seat instead and the client draws them wherever the seat is:

int[] ids = engine.emotes().passengerEntityIds(rider);   // bones, carried models, hands
double lift = engine.emotes().rigOriginOffset();         // blocks above the feet

Every display hangs off one point, rigOriginOffset() above the rider's feet, so put your seat's passenger position there. Read the number rather than copying it, as it follows the size the engine draws players at.

Mount with a packet, not addPassenger

The engine teleports every display every tick, and a Bukkit teleport of a passenger dismounts it. Send a client-side passengers packet instead. That packet is the vehicle's whole list: include whoever really sits there, resend it when the server sends its own, and send it to anybody who starts seeing the seat.

The ids change: putting a rig on spawns every display, and swapping to an emote with different carried models spawns those again. EmoteRigSpawnEvent fires each time with the new list, so mount there. It fires for every rig, so check the player is yours.

Rebuilding the rigs#

A player's rig wears their skin, and rigs are baked into a pack. The engine rebakes when a new skin arrives but only sends that build to whoever joins next. To make everybody see everybody's current skin now (a minigame dressing players in emotes), call engine.reloadRigs() on the main thread. On 1.20.3+, where packs stack, that resends only the small rig pack and fires RigPackBuildEvent; on older versions it is a full reload().

3D armour#

ContentId santa = ContentId.parse("christmas:santa").orElseThrow();
 
engine.armor().available();                           // false before 1.21.4
engine.armor().info(santa);                           // its name and pieces
engine.armor().create(santa, ArmorPiece.HELMET);      // one piece, as an item
ArmorResult result = engine.armor().wear(player, santa); // the whole set, now
engine.armor().wearing(player);                       // which set each worn piece is from
engine.armor().strip(player);                         // every 3D piece off

A set from a content folder is namespace:path; one pushed from Studio is studio:<id>. Each piece is an ordinary item that goes on by hand like armour, so wear is a shortcut for all four. Whatever was in a slot goes back to the player (or drops at their feet if their inventory is full) and is listed in ArmorResult.displaced(). strip removes only 3D pieces and gives them to nobody. ArmorResult has a reason: WORN, UNKNOWN_SET or UNSUPPORTED.

A set pushed from Studio is only drawn for players holding that push's pack. Main thread only, except available, ids and info. See 3D armour.

3D shader objects#

Needs RP Engine 0.2.10 or newer, and a server on Minecraft 1.21.4 or newer. engine.shaderObjects() places and drives 3D objects:

ShaderObjects objects = engine.shaderObjects();
if (!objects.supported()) return;                     // false before 1.21.4
ContentId nuke = ContentId.parse("studio:nuke").orElseThrow();
ShaderObject bomb = objects.spawn(location, nuke).orElseThrow();
 
bomb.moveBy(new Vector(0, -20, 0), 50, Easing.IN);    // over 50 ticks
bomb.turnTo(90, 0, 0, 40, Easing.IN_OUT);             // yaw, pitch, roll
bomb.resize(40, 2, Easing.LINEAR);                    // diameter in blocks
bomb.state("detonate");                               // starts its clock
bomb.play("drop");                                    // a sequence from objects/

spawn(location, id, size) sets the size; without it the object's own size is used. at(entity), near(location, radius) and loaded() find objects already placed, and ids() and info(id) list what the server has (states, sequences, size). On a handle, replay(), settle(), seed(n), moveTo, stop() and remove() match the commands; state(name) and play(name) return false for a name the object doesn't have. A time of 0 ticks is instant. Easing is LINEAR, IN, OUT or IN_OUT.

A handle lasts as long as its entity, and the object's state, place, turn and size survive a restart; a running sequence or move does not. Main thread only, except ids and info.

Sounds#

engine.sounds().play(player, id);       // only this player hears it
engine.sounds().playAt(location, id);   // everybody nearby hears it here
engine.sounds().playFrom(entity, id);   // everybody nearby hears it follow this entity
engine.sounds().stop(player, id);       // end it early for this player

All three use the sound's declared category, volume and pitch, and each has an overload that takes volume and pitch. Use playFrom for anything that moves. Each returns false for an unknown id. A stereo file plays at full volume wherever it was played from (Minecraft only places mono sounds in the world), so use mono for anything heard from a spot. Playing and stopping are main thread only; ids() and info() are safe from any thread.

Music#

Background music is a playlist of the pack's sounds. Each player hears one piece at a time: what your plugin started for them, otherwise the music of the place they stand in.

Music music = engine.music();
music.play(player, bossTheme);   // from its first track, until stopped
music.skip(player);              // on to the next track
music.stop(player);              // end it; the place's music comes back
music.playing(player);           // the piece playing for them, if any
music.track(player);             // and the track

play beats the place's music, so a boss fight can start its own and stop it when the boss dies. play returns false for an unknown id, skip when nothing is playing. Calling stop with nothing started silences the place's music until they leave that place. Main thread only, except ids() and info().

HUD overlays#

An overlay is a picture kept on a player's screen: a stat block, a health bar, a crouch indicator. Shader objects made in Studio are overlays, and so are the ones in a huds/ folder. Server owners draw them; your plugin decides who sees one and what numbers are in it.

engine.overlays().show(player, "studio:health");  // stays until hidden
engine.overlays().hide(player, "studio:health");
engine.overlays().hideAll(player);
engine.overlays().showing(player);                // what they have on

An overlay made in Studio has the id studio:<id>, where <id> is the one on its card on the pack page. There is no duration: to show one for five seconds, schedule the hide.

Feeding it values#

An overlay's labels and progress bars can print {name} placeholders. Any name works.

engine.overlays().set(player, "stamina", "40");
engine.overlays().set(player, "stamina", null);   // remove it
engine.overlays().value(player, "stamina");       // what you last set

A placeholder is looked up in this order, and the first answer wins:

  1. Whatever you set. A built-in with the same name never overrules it.
  2. The built-ins: player, name, displayname, uuid, world, x, y, z, direction, health, health_max, health_percent, food, air, level, xp, xp_points, xp_needed, ping, gamemode, online, max_online, time, day. See HUD overlays.
  3. PlaceholderAPI, if the server has it.

A name none of them knows draws as nothing rather than as {name}.

Values are per player, not per overlay, and a progress bar reads the same names, so a stamina bar out of 100 needs only the one set call. They are also the values dialogs print.

The same values can change more than words. A piece of a Studio overlay can be told, in its From code settings, to show only while a condition holds, to switch to one of its named colours, or (a progress bar) to take its length or a colour threshold from a value. Your plugin just sets the value:

engine.overlays().set(player, "status", "poisoned");  // a named colour
engine.overlays().set(player, "rank", "vip");         // Show when {rank} == "vip"
engine.overlays().set(player, "mana_len", "120");     // a bar 120 pixels long

Colours can only be ones the overlay names, and shapes can't change at all: they are compiled into the resource pack. See Changing it from code.

set and value are safe from any thread. They only record the value; the next tick draws it, so six set calls in a row cost one redraw, and setting a value to what it already was costs nothing. show, hide and hideAll are main thread only.

Laying a value out#

To build a line from several pieces (a kill feed: name, weapon icon, name), use the engine's own measurements:

int nameWidth = engine.overlays().width("Steve");  // pixels the cursor moves
String gap  = engine.overlays().space(5);          // five pixels of nothing
String back = engine.overlays().space(-12);        // twelve pixels back, to draw over

width knows vanilla's letters, every icon in the pack and the spaces space makes. Both work in any text drawn in the default font.

Dialogs#

A dialog is a screen of its own, such as a store, a settings menu or a punish menu, on Minecraft 1.21.6 and newer. Server owners make them in Studio or in a dialogs/ folder; your plugin decides when one opens and what values are on it.

engine.dialogs().show(player, "studio:shop");
engine.dialogs().close(player);
engine.dialogs().ids();                        // what this server has
engine.dialogs().info(id);                     // name, command, settings it declares
engine.dialogs().supported();                  // false before 1.21.6
engine.dialogs().canShow(player, id);          // would show get as far as the client?

A dialog made in Studio has the id studio:<id>, where <id> is the one in its editor, and each page after the first is studio:<id>.<page>.

show returns false rather than throwing when the server is older than 1.21.6, there is no such dialog, the player is offline, or they don't hold the pack the dialog's picture is in. canShow checks all of that first. What it can't promise is that the game accepts the dialog's JSON; a refusal is logged to the console.

A dialog can declare a command players open it with (/shop). The engine registers it; DialogInfo.command() and permission() say what it is. DialogInfo.quickAction() says whether it is on the Quick Actions key (G).

A dialog opens straight away even if it was pushed a moment ago. pending() only tells you whether its id is in the server's registry yet, which matters only to a datapack or command block of your own that names it.

When a player clicks a button#

RP Engine fires no event for a dialog click. A button runs a command, so the way to react is to make that command yours: register /buy (or whatever the button runs) in your plugin.yml and handle it in your command executor. The command runs as the player who clicked, with {name} placeholders already filled in. A button can run several commands in order, and a Run as console button runs its command as the console instead. The one dialog event is DialogSlotMoveEvent, for item slots.

If you want click events in Java, with typed inputs, use Dialog Engine, a separate plugin built for that.

Values on it#

A dialog's words can print {name} placeholders, on its screen as well as in its title, tooltips, buttons and commands. See Live values. Hand values over as you open it:

engine.dialogs().show(staff, "studio:punish", Map.of("target", suspect.getName()));
// "Punish {target}" opens as "Punish Steve", the name and the face on its card are Steve's,
// and a reason that runs "mute {target} 1h" runs "mute Steve 1h".

Or publish a value once, for every dialog and HUD that prints it. These are the same values overlays().set publishes, one set per player:

engine.dialogs().set(player, "coins", String.valueOf(balance));
engine.dialogs().set(player, "coins", null);    // remove it
engine.dialogs().value(player, "coins");        // what you last set

A list (rows drawn in Studio, one per item) is filled all at once:

engine.dialogs().list(player, "warps", List.of("Spawn", "Shop", "Arena"));
// More than one field a row: "" is the row's own value, "note" is {warps_note}.
engine.dialogs().listRows(player, "warps", List.of(Map.of("", "Spawn", "note", "Where you start")));

It sets warps_1, warps_2 and so on plus warps_count, and clears items a longer list set before. Rows past the end are hidden, clicks and all. The command that opens a dialog can pass a list too, warps=Spawn,Shop,Arena, for that opening only.

A placeholder is looked up in this order, and the first answer wins:

  1. What you passed to show (case-insensitive).
  2. What you published with set (or overlays().set).
  3. The built-ins, the same ones a HUD has, all about the player viewing it.
  4. PlaceholderAPI, if the server has it.
  5. The player's own settings.

Settings come last because players change them themselves with a command. A setting can never stand in for a real value, but a setting that shares a name with anything above shows that value instead, so give settings names of their own.

A name nothing answers is left as written, so a dialog that says {VIP} stays as it is. Formatting codes are removed from values. On a Studio dialog's screen, a value is limited to characters the game's small font draws (others become ?) and is cut short with "…" if it doesn't fit its line.

A dialog is drawn once, as it opens. A value that changes while it is on screen shows next time. Call reopen after a click changed something; it opens the dialog again with the values it was opened with:

// A Buy button on the store runs /buy sword. In your command:
economy.withdraw(player, price);
engine.dialogs().set(player, "coins", String.valueOf(economy.getBalance(player)));
engine.dialogs().reopen(player);   // the store again, with the new balance on it

The server isn't told when a player closes a dialog, so reopen opens it whether it is still on screen or not. Call it in answer to a click, not on a timer.

Player settings#

A switch, slider or choice on a Studio dialog can be bound to a player setting: every player has their own value, the control shows it, and clicking it changes it. Read or change one from your plugin:

engine.dialogs().setting(player, "show_sidebar");            // Optional: "on" or "off"
engine.dialogs().setSetting(player, "show_sidebar", "off");  // as if they clicked it
engine.dialogs().setSetting(player, "show_sidebar", null);   // back to the default
engine.dialogs().info(id).map(DialogInfo::variables);        // {"show_sidebar": ["on", "off"]}

Settings are stored on the player and survive restarts. PlaceholderAPI reads them as %rpengine_var_<name>%. Limits:

Namelower-case letters, digits and _, up to 32 characters
Valueletters, digits, _, . and -, up to 32 characters
Per player64 settings

setSetting returns false for anything outside those. Your plugin may set any name, not only one a dialog declares. Nothing reopens on its own, so call reopen if they're looking at it.

Your own container in a dialog#

A Studio dialog can have a grid of item slots showing a container "from a plugin", by name. Register one under that name and the grid shows your inventory for whoever opens it, and lets them move items in and out where the grid allows it:

engine.dialogs().container("bank", new DialogContainer() {
    @Override
    public Inventory inventory(Player viewer) {
        return banks.of(viewer);
    }
 
    @Override
    public boolean mayPlace(Player viewer, int slot, ItemStack stack) {
        return !stack.getType().name().endsWith("SHULKER_BOX");
    }
 
    @Override
    public void changed(Player viewer) {
        banks.save(viewer);   // after every move that touched it
    }
});

The engine reads your inventory as each dialog opens and writes to it as each move happens, on the main thread. A move reads both slots at that moment, so a stale screen can't duplicate items. DialogSlotMoveEvent fires before every move in any container.

set and value are safe from any thread. Everything else in dialogs touches a player and is main thread only.

Vehicles#

engine.vehicles().spawn(location, id);   // park one, ready to get into
engine.vehicles().of(player);            // the vehicle they are in
engine.vehicles().at(entity);            // is this thing part of a vehicle?
engine.vehicles().near(location, 16);    // nearest first
engine.vehicles().loaded();              // every one in a loaded chunk
engine.vehicles().isRiding(uuid);        // safe from any thread

A Vehicle is a handle on one standing in a world, occupied or not:

Vehicle car = engine.vehicles().of(player).orElseThrow();
car.speed();          // blocks per second, negative in reverse
car.states();         // MOVING, TURNING, AIRBORNE, IDLE ...
car.driver();         // Optional<Player>
car.occupants();      // driver first, then in seat order
car.seat(other, 1);   // put somebody in seat 1
car.eject(other);     // and take them out
car.submersion();     // how deep in water, in blocks. 0 out of it
car.pointOn(-0.7, 0.6, 1.5);   // where a point on the bodywork is, in the world

submersion() is a depth; VehicleState.SUBMERGED only means "in a water block", which a puddle also is. A vehicle whose medium isn't water floods and stops in water; whether that wrecks it is up to you.

Vehicle definitions can carry addons: blocks for your plugin. Read yours with vehicle.info().addon("my-addon"); the engine passes it through untouched.

Fuel, and anything else that stops a vehicle going#

setEnabled(false) is the switch. A disabled vehicle ignores its driver and coasts to a halt, still falls and floats, and people get in and out as normal. The flag is saved on the chassis, so a car that ran dry is still dry after a restart.

NamespacedKey fuel = car.key(this, "fuel");
 
@EventHandler
public void onState(VehicleStateEvent event) {
    // Burn only while it is actually going somewhere.
    if (event.entered(VehicleState.MOVING)) burners.add(event.vehicle());
    if (event.left(VehicleState.MOVING)) burners.remove(event.vehicle());
}
 
// once a second
for (Vehicle vehicle : burners) {
    int left = vehicle.data().getOrDefault(fuel, PersistentDataType.INTEGER, 0) - 1;
    vehicle.data().set(fuel, PersistentDataType.INTEGER, Math.max(0, left));
    vehicle.setEnabled(left > 0);
}

data() is the chassis's persistent data and survives everything the vehicle does. uniqueId() is the chassis's id and the one stable key: seats and model entities get new ids on every chunk load, so never store those.

MethodWhat it doesSaved?
setEnabled(boolean)Ignores the driver and coasts to a haltYes
setSpeedLimit(bps)Lower top speed, braking and reverse scaled to match. An aircraft limited below takeoff speed can't take offNo
setHandling(speed, turn)Scales what it can do, as fractions of its definitionNo
setDamage(VehicleDamage)Per-wheel condition, engine power and steering pull, read by the handling modelNo
setDescent(bps)An aircraft comes down at this rate whatever it's doing. 0 holds height, negative clears itNo
setPosture(pitch, roll)A drawn lean on top of the physicsNo
stop()Dead this tick, like hitting a wall

Re-apply the unsaved ones when you pick a vehicle up again (after a chunk load or restart). VehicleDamage.NONE is the vehicle exactly as its pack defines it:

car.setDamage(VehicleDamage.builder()
        .wheel(VehicleCorner.FRONT_LEFT, 0)     // torn off
        .wheel(VehicleCorner.REAR_LEFT, 0.45)   // buckled
        .enginePower(0.7)
        .steeringPull(-3)                       // pulls left
        .build());

A wheel runs from 1 (sound) to 0 (gone). Damage is not health and never changes the art; the engine has no opinion on how much punishment a vehicle takes. That is yours to track in data().

Who may get in is an event, not a method

There is no setLocked. Cancel VehicleEnterEvent and they stay standing. VehicleExitEvent can't be cancelled, so a player is never trapped; to keep somebody in a race, put them back with seat.

Collisions and parts#

VehicleImpactEvent fires once per meaningful collision (cause(): VEHICLE, WORLD or LANDING). first() and, for a vehicle pair, second() each carry the velocity before and the change, the contact normal, closing speed, impulse, spin change, area() (which side took it) and contactOffset() (where on the vehicle, in its own frame: x right, y up from its base, z forward). A zero offset means the engine can't say where. For WORLD impacts it is a face, not a point.

To react:

  • applyImpulse(deltaVelocity, spinDelta) changes the physical response; velocity() reads it. An upward part lifts a land vehicle off the ground and gravity brings it back, so a jump pad is one call (RP Engine 0.2.45 and newer).
  • scuff(area, contact, amount) adds a persistent scrape (amount 0 to 1, at most ten marks; a repeat hit in the same place deepens the mark).
  • parts() lists an animated model's bones, partOffset(name) says where one sits, and detachPart(name, velocity, spin, despawnTicks) knocks it off. detachedParts() is saved on the chassis.
  • showStatus(text) borrows the vehicle's action-bar line for a moment.

Building a vehicle the engine does not have#

A skateboard is pushed rather than throttled, tucks for speed and spins in the air. None of that is in the format, but the handle exposes the pieces:

Vehicle board = engine.vehicles().of(player).orElseThrow();
VehicleInput keys = board.input();          // what the driver is pressing, this tick
if (keys.forward() && board.groundSpeed() < 7) {
    board.nudge(2.2);                       // a kick: +2.2 blocks/s along the heading
    board.dress(player, "myplugin_push");   // wear this emote over the seat's states
}
if (board.is(VehicleState.AIRBORNE) && keys.left()) board.spin(-360);
board.undress(player);                      // back to the seat's own table
board.dressVariant(player, "goofy");        // this rider's own variant of every state
board.turnOccupant(player, 270.0);          // face a different way from the seat; null resets
board.holdOccupant(player, true);           // sneak no longer dismounts while moving
board.wallRiding();                         // is it on a wall right now?
  • input() is the same input the physics read, on the same tick. Where keys can't be read (Spigot, or before 1.21.4) keys() is false and only throttle() means anything. Nothing in the engine acts on sprint, so it's yours to use.
  • dressVariant turns moving into moving_goofy and so on, falling back to the plain emote where no variant exists.
  • Sneak is Minecraft's dismount. Call holdOccupant once, when they get on: the dismount happens before your next tick task could react. A held rider can still get off by tapping sneak, and the hold stops applying once the vehicle is below half a block a second, so nobody is ever trapped. Tell riders what the key does now.

A driver that isn't a player#

drive(keys) drives a vehicle as though somebody in the driver's seat were holding those keys, from the next tick until you call it again (RP Engine 0.2.51 and newer). Nobody has to be in it, and a real driver's keys are ignored meanwhile. It's for computer racers, demo laps and ghosts. The handling is a player's, setEnabled(false) still stops it, and input() reports your keys, so code that reacts to what the driver pressed works for this driver too. drive(null) hands it back. Steer and throttle run from -1 to 1 (steer: left to right); the space key is the handbrake, a jump or a climb, depending on the vehicle.

VehicleInput keys = new VehicleInput(true, 1.0, -0.4,   // throttle 1, steer 0.4 to the left
        true, false, true, false, false, false, false);   // forward, back, left, right, space, sprint, sneak
kart.drive(keys);
kart.carry(mannequin, 0);                                 // somebody to see in the driver's seat

carry(entity, seat) sits a figure that isn't a player in a seat, a mannequin or an NPC. It's for show: it doesn't drive and it isn't an occupant. A seat is rebuilt when its chunk loads again, which drops it, so check getVehicle() and carry it again when it has come off.

Moving the vehicle's own model#

dress is for the rider. perform plays one of the model's animations instead of whatever its animations: table says:

bike.perform("wheelie");   // the whole bike tips back about the rear axle
// ... count your ticks ...
bike.rest();               // back to the state table, wheels where they were

It works on a vehicle with no animations: at all. A performed animation loops until you call rest(), so you time the trick. It runs on real time even on a vehicle with animation-follows-speed, and the wheels pick up where they were afterwards. One animation plays at a time, so a trick that needs the wheels turning must turn them itself.

Every vehicle handle method is main thread only. ids, info and isRiding are safe anywhere.

Icons in your own text#

String line = engine.icons().format(config.getString("welcome"));

Every :namespace:id: becomes its picture. An ID naming nothing is left exactly as written.

An animated icon is animated by you redrawing it. Each frame is its own character, so format and character give the first frame, which is right for anything you send once. For text you send repeatedly, ask for the current frame:

// Once a tick, or as often as your scoreboard redraws.
sidebar.setLine(0, engine.icons().formatNow(":mypack:loading: Loading"));
engine.icons().characterNow(id).ifPresent(frame -> bar.setTitle(frame + " Boss"));

IconInfo has frames() (1 for a still), animated(), fps(), loops(), frameAt(elapsedMillis), and character(frame) / codepoint(frame) for one frame (from 0). Redrawing once a tick shows every frame of anything up to 20 fps. A looping icon is timed from the epoch, a one-shot from server start. The PlaceholderAPI form %rpengine_icon_<namespace:id>% is always the current frame.

IconInfo.aliases() and permission() apply to players typing the icon in chat. format ignores both.

Text Effects in your own text#

A Text Effect is a colour: text drawn in exactly that colour wears the effect, whatever sent it. So the API hands you the colour, and two ways to write text in it.

TextEffects effects = engine.textEffects();
 
// Tags in your own config, resolved the way RP Engine's own files are.
player.sendMessage(effects.format(config.getString("welcome"))); // "Welcome to <fx:rainbow>Skyblock</fx>!"
 
// One effect by name: a content folder's before a Studio one.
effects.find("fire").ifPresent(fire -> {
    meta.setDisplayName(effects.apply(fire.id(), "Flame Sword"));
    // Adventure, on Paper: the colour is all it takes.
    player.sendMessage(Component.text("Hot!", TextColor.color(fire.rgb())));
});
MethodWhat it does
ids()Every Text Effect, both sources, sorted
info(id)A TextEffectInfo, by ID
find(name)By what somebody typed: mypack:fire, or a bare fire
apply(id, text)text in the effect, as a legacy string that resets after itself
format(text)Every <fx:name>...</fx> replaced. A tag naming nothing is left as written

TextEffectInfo has id(), name(), color() (#rrggbb), rgb(), source() (CONTENT or STUDIO) and preset(). apply and format write legacy codes (§x§f§c...), which every Spigot API takes. A colour code inside the text ends the effect; bold and italic do not. Safe from any thread.

VFX#

A VFX is a spell effect the engine plays in the world. Play one at a place, or on a player (at their feet, facing their way), and keep the handle if you want to end it early.

Vfx vfx = engine.vfx();
 
vfx.find("frost_nova").ifPresent(info -> {
    vfx.play(info.id(), player).ifPresent(play -> {
        // play.running() until it ends; play.stop() ends it now.
    });
});
MethodWhat it does
ids()Every VFX, both sources, sorted
info(id)A VfxInfo, by ID
find(name)By what somebody typed: mypack:frost, or a bare frost
play(id, location)Plays it there, facing the location's yaw. Empty for an unknown ID, or below 1.21.4
play(id, player)Plays it where the player stands, facing their way
play(id, location, caster, options)Plays it as a VfxOptions says (below); caster is whose placeholders a condition reads, and may be null
play(id, player, options)The same, cast on a player
playing()Every VfxPlay playing now
stop(tag) / stop(player)Ends the effects with that tag, or cast on that player, and says how many
stopAll()Ends them all, and says how many there were

VfxOptions is every casting option: loop(), loopFor(ticks), loopTimes(n), until(condition), whileTrue(condition), stopWhen(play -> ...) (a check of your own, run every checkEvery(ticks)), end(End.FINISH | End.CUT), speed, scale, turn, offset(left, up, ahead), follow(entity), follow(vehicle), delay, vary, seed, sounds, volume, viewers(Viewers.EVERYONE | Viewers.CASTER) and tag.

follow(vehicle) rides with a vehicle and turns with the way its body points, not with where its driver is looking, so an exhaust stays at the tail through a corner and an effect made in front of the caster plays in front of the vehicle. Cast it at vehicle.location(), which carries that heading:

vfx.play(boost, kart.location(), driver, VfxOptions.builder()
        .follow(kart)
        .build());
vfx.play(id, player, VfxOptions.builder()
        .loop()
        .stopWhen(play -> !combat.isFighting(player))
        .follow(player)
        .tag("combat-aura")
        .build());

VfxInfo has id(), name(), durationTicks(), source() (CONTENT or STUDIO) and preset(). A VfxPlay has id(), location(), running(), stop() (now), finish() (a loop plays out its fade), options(), age() and passes(). A Studio VFX is shown only to the players whose pack has it. Main thread only.

Custom mobs#

The custom mobs a content folder's entities/ declares and the ones a Studio sync carries (studio:<id>) are one catalogue.

Mobs mobs = engine.mobs();
ContentId rhino = ContentId.parse("zoo:rhino").orElseThrow();
 
mobs.info(rhino).ifPresent(info -> info.animations());   // state -> animation name
Entity spawned = mobs.spawn(location, rhino).orElseThrow();
mobs.idOf(entity);                       // which custom mob this is, if any
mobs.state(entity);                      // what it is showing: MobState.WALK, ...
mobs.play(entity, MobState.ATTACK);      // as if it had happened: a one-shot, then back
mobs.play(entity, "roar", 0);            // any animation of its model, for its own length
ItemStack egg = mobs.egg(rhino, 1).orElseThrow();   // hatches that mob where used

The states are MobState, and their order is their precedence: DEATH, SPAWN, ATTACK and HURT play once, and only a higher one cuts them short; RUN, WALK and IDLE loop, and fall through to the next when the mob has no animation for them. play(entity, state) is refused (it returns false) when a higher one-shot is playing or the mob has nothing mapped for that state. play(entity, name, seconds) holds the stage for that long (0 for the animation's own length), then the mob goes back to what it is doing.

EntityInfo also says how a mob looks after itself: hurtFlash(), followRange(), glowing(), fireImmune(), and three optional blocks: bossBar() (color(), style(), range()), ranged() (projectile(), damage(), cooldown(), range()) and spawning() (worlds(), biomes(), replaces(), chance(), light, time, group and maxPerChunk()). The builder takes each, so a mob you register from code can have them too.

A projectile a custom mob fired carries the damage it was given. Setting a different damage on it in your own EntityDamageByEntityEvent, at a priority above NORMAL, wins.

Listen for MobStateChangeEvent to follow what a mob is doing. It fires when what is playing changes, so a state the mob has no animation for never appears. EntityDeathEvent still hands you the drops list, after the mob's own drops: have replaced the vanilla loot.

Everything here touches an entity, so it is main thread only, or on Folia the thread that owns the mob or the place. Needs RP Engine 0.2.51 or newer.

Skills#

A custom mob's skills are MythicMobs' skill lines, and you extend the language the way MythicMobs' add-ons do: register a mechanic, a condition or a targeter by name, and every mob file can use it. Needs RP Engine 0.2.53 or newer.

MobSkills skills = engine.skills();
 
// - myplugin:smite{damage=6} @target ~onAttack 0.2
skills.registerMechanic("myplugin:smite", cast -> {
    cast.target().ifPresent(target -> {
        target.getWorld().strikeLightningEffect(target.getLocation());
        if (target instanceof LivingEntity living) {
            living.damage(cast.number("damage", 4, "d") * cast.power(), cast.caster());
        }
    });
    return true;
}, "smite");
 
// ?myplugin:inarena{name=pit}
skills.registerCondition("myplugin:inarena", cast ->
        arenas.contains(cast.string("name", ""), cast.location()));
 
// @myplugin:team{team=red}
skills.registerTargeter("myplugin:team", new SkillTargeter() {
    @Override
    public Collection<Entity> entities(SkillCast cast) {
        return new ArrayList<>(teams.members(cast.string("team", "red")));
    }
});
 
skills.cast(boss, "bosses:GroundSlam", player);   // a metaskill, cooldown and conditions applying
skills.stance(boss);                             // Optional<String>
skills.stance(boss, "enraged");

SkillCast is what each one is handed: caster(), mob(), trigger(), target() (the entity, when the target is one) and location() (always), power(), stance(), and the line's options by any of their names, with placeholders such as <caster.hp> filled in and a range like 2to5 picked: string, number, integer and bool, plus fill(text) for text of your own. A mechanic is called once per target, on the caster's thread. A targeter's own options are read the same way; the shared ones (limit, sort, the offsets, conditions) are applied to what it returns.

Names are looked up when a line runs, so registering in onEnable, after RP Engine has loaded its content, is in time. Give yours a prefix (myplugin:smite). A prefixed name nobody has registered yet is kept at load with a note that it "is not registered yet" and runs from the moment you register it, with no reload, where an unknown bare name is a typo and its line is skipped. Registering a name that already exists replaces it, RP Engine's own included. Names are case-insensitive.

Listen for MobSkillEvent to see a skill before it runs, or cancel it; see Events.

Events#

All in ai.resourcepack.engine.api.event, and cancellable unless the row says otherwise.

EventWhen
ContentLoadEventContent finished loading and the packs are built. A reload replaces every definition, so rebuild anything you cached here. cause() is STARTUP or a reload. Not cancellable
PackSendEventA pack went out to a player. pack() is the built pack; its uuid() is the id PlayerResourcePackStatusEvent reports on 1.20.3+, so you can match the two. Not cancellable
RigPackBuildEventThe emote rigs were rebaked: on every content load, or alone for a new skin or reloadRigs(). alone() and separate() say which. Not cancellable
ItemUseEventA custom item was right- or left-clicked. block() and face() say what was clicked (null in the air). Cancelling cancels the vanilla use too
ModelPlaceEventA model is about to be put down. getPlayer() is who is placing it, model() the model's id, block() the block it goes in (its location), and stack() the stack in their hand, so you can read what the item carries (null when a plugin placed it). Parking a vehicle from its item and hatching a custom mob from its egg fire it too, with the vehicle's or mob's id as model()
ModelPlacedEventA model was put down and is standing. placement() is its handle and stack() the stack it came from, so you can copy what the item carries onto the placement's data(). Not cancellable
ModelBreakEventA model is about to be broken. model(), location(), player() (null if nobody broke it), hitbox() (its Interaction entity, whose UUID tells two copies of one model apart) and placement(). Has a drop flag separate from cancelling
ModelInteractEventA placed model was right-clicked, from your content folder or from Studio. Has hitbox(), placement() and clickedPosition(). Cancelling stops everything the engine would do with the click
ModelAnimationEventA model is about to play an animation: getPlacement(), getAnimation(), getCause() and getPlayer() (who triggered it, if anyone)
ModelAnimationEndEventOne ended: finished, replaced or stopped. Not cancellable
ModelSeatEventSomebody is about to sit on a chair, a seat bone or a vehicle seat. placement() or vehicle() says what it belongs to
ModelBindEventA model is going on, or coming off, an entity that isn't ours (a boss, an NPC)
VehicleEnterEventSomebody is about to get into a vehicle. Fires after ModelSeatEvent for the same seat
VehicleExitEventSomebody got out or was taken out, with the reason: dismounted, ejected, quit, reloaded, removed, unloaded, shutdown. After RELOADED they're put back a tick later with no enter event. Not cancellable
VehicleMoveEventA vehicle is about to move (once per tick, only while moving). Cancelling stops it like a wall. Use it for regions, not fuel
VehicleStateEventWhat a vehicle is doing changed: set off, stopped, took off, went under. Not cancellable
VehicleImpactEventA vehicle hit another vehicle, the world, or the ground. See Collisions and parts. Not cancellable
VehicleBailEventA bad landing is about to throw a rider off. Cancelling keeps them aboard
MobStateChangeEventA custom mob's animation state changed: previous() and state(), null for its rest pose. Not cancellable
MobSkillEventA custom mob is about to cast a skill: one of its own skills: lines (skill() is the line as written) or a metaskill (skill() is its id), with trigger() (attack, timer, api and so on) and triggerEntity(). Fires after the line's chance, health and conditions passed. Cancelling skips it and spends no cooldown
EntityDeathEventA custom entity died. Says what it was, killer(), and drops(): Bukkit's live drop list, so editing it changes the loot. Not cancellable
PlayerLiquidEventSomebody went into or came out of one of your liquids. Not cancellable
EmoteStartEventAn emote is about to start
EmoteEndEventAn emote ended, with the reason: finished, stopped, moved, damaged, quit, shutdown
EmoteRigSpawnEventA rig's displays were spawned. Carries the new entity ids. Not cancellable
DialogSlotMoveEventA player is about to move a stack in a dialog's item slots. dialog(), from() and to() (slot keys like inv/5, ender/3, api.bank/0), moving() and onto() (the stack already there). The only dialog event: a button click runs a command instead, see When a player clicks a button

The engine decides whether something can physically happen, never whether it is allowed to. Region protection, plot ownership and event worlds are rules about your server, and these events are where you enforce them.

@EventHandler
public void onPlace(ModelPlaceEvent event) {
    if (!myRegions.mayBuild(event.getPlayer(), event.block())) {
        event.setCancelled(true);
    }
}
 
@EventHandler
public void onLoad(ContentLoadEvent event) {
    menus.rebuild();
}

A plugin loading after RP Engine misses the first load

The STARTUP event has already fired before your listener exists. Don't work around it with a delayed task: query the API in your own onEnable, and use the event for reloads after that.

Priority#

The engine reads every one of these events after all handlers have run, so priority only orders you against other plugins. Listen at NORMAL unless you are deliberately arbitrating with another plugin, and treat MONITOR as read-only (a cancel there still counts).

Vanilla's own events are different. The engine listens to those at LOW with ignoreCancelled = true, so cancelling a PlayerInteractEvent at LOWEST stops a custom item's use before RP Engine sees the click, which is usually what a protection plugin wants.

Shipping content in your own jar#

An addon carries its models, items, vehicles and emotes as an ordinary content folder inside its jar, under resources/content/<namespace>/, and installs it on enable:

try {
    if (AddonContent.install(this, engine, "skateboards")) {
        engine.reload();   // only when something actually changed
    }
} catch (IOException e) {
    getLogger().severe("Could not install content: " + e.getMessage());
    getServer().getPluginManager().disablePlugin(this);
}

It copies into plugins/RPEngine/content/<namespace>/ and returns true only when something changed, so restarting on the same jar copies and reloads nothing. A reload rebuilds and resends every pack, so don't call it otherwise.

A new build updates its own files and leaves everything else alone. If the server owner edited one of your shipped files, that edit survives the update: the console names the file and says to delete it to take the new version. Use your plugin's name as the namespace, and put RP Engine in depend.

Registering content from code#

A plugin can add content directly instead of shipping a folder. Claim a namespace, define into the handle you get back, and the pack builder picks it up on the next build.

ClaimResult claim = engine.registration()
        .claim("myplugin", ContentSource.EMBEDDED);
 
claim.namespace().ifPresent(ns -> {
    ns.define(ContentKind.ITEM, "ruby");
    // ... and ns.release() when your plugin disables
});

If the claim fails, reason() says why and heldBy() who has it. Holding myplugin can't define otherpack:thing, so two sources can't overwrite each other. release() drops the namespace and everything in it. Content from a plugin goes into the same registry under the same rules as a hand-written folder.

Threading#

Reads about what the server holds (ids, info, all of ContentRegistry) are safe from any thread. Anything touching a player, an entity or a world is main thread only, and its javadoc says so.

On Folia there is no main thread, so "main thread" means the thread that owns what the call touches: the player's or entity's own scheduler for anything about them (player.getScheduler().run(...)), and the region scheduler for anything at a place (Bukkit.getRegionScheduler().run(plugin, location, ...)), such as placing a model or playing a VFX at a spot. A call from the global region or another region's thread is the same mistake a call from an async thread is on Paper. Everything RP Engine does on its own already follows this, so your plugin only has to for the calls it makes.