resourcepack.ai
DOCS

For developers

Emotes from code

An emote animates the player rather than a placed model. Their body is replaced with a rig wearing their own skin, they're made invisible for the duration, and they're put back exactly where they were afterwards.

Everything here needs rpai — see Plugin API for how to get it.

Play one#

EmoteResult result = rpai.emotes().play(player, "Bow");
 
if (!result.started()) {
    player.sendMessage("Can't do that right now.");
}

That's the whole happy path. The interesting part is the result.

Saying why it didn't work#

An emote can be refused for about thirty different reasons, and they don't have the same fix — "you're not on the ground" and "this pack was synced without your skin" send a player to completely different places. So the API hands back a reason, not a sentence, and you write the words:

EmoteResult result = rpai.emotes().play(player, "Bow");
if (result.started()) return;
 
player.sendMessage(switch (result.reason()) {
    case NOT_ON_GROUND   -> "You need to be standing on solid ground.";
    case ALREADY_EMOTING -> "You're already doing one.";
    case UNKNOWN_EMOTE   -> "No emote called " + result.subject()
                              + ". Try: " + String.join(", ", result.options());
    case CAST_NOT_ONLINE -> result.subject() + " isn't online.";
    default              -> "Can't do that right now.";
});

Always have a default branch

The reason list only ever grows, so always have an else / default branch. A Kotlin when used as an expression needs one anyway; a Java switch will happily compile without one and then say nothing at all the first time we add a reason.

The extras on a result are worth knowing:

CallWhat it carries
started()Whether it's playing
reason()Why, typed. STARTED when it worked
subject()Who or what the reason is about — a player's name, or the words that named no emote
options()What would have worked: the emote names that exist, or the cast slots to fill
borrowedSkin()It's playing, but wearing the pack's fallback skin rather than theirs

borrowedSkin() is worth surfacing once rather than refusing over. They're emoting — just not as themselves, because they weren't in the party when the pack was pushed. The fix is a re-sync they can choose to do later.

Check before you offer#

if (!rpai.emotes().canPerform(player)) {
    // No rig baked for them in the pack they're wearing.
    // They joined after the last Sync — nothing they did wrong.
}

Why an emote needs the player to be in the pack

An emote renders somebody's skin, and a resource pack can't reach a live one. So a pushed pack carries a baked copy of each recipient's skin, and a player who wasn't in the party at push time has no rig to wear. That's the one thing about emotes that surprises everybody, and canPerform is how you find out before you build them a menu they can't use.

Duets and group emotes#

Some emotes are authored for more than one person — a handshake, a high five. They can't be played alone, and the cast has to be exactly the size the emote expects.

Optional<EmoteInfo> info = rpai.emotes().info("Handshake");
 
info.ifPresent(emote -> {
    if (emote.needsCast()) {
        // emote.castSlots() is ["partner"] — labels for a prompt, not names
        rpai.emotes().play(player, "Handshake", List.of(partner));
    }
});

The people you name are teleported into place, turned to face where the emote puts them, and put back afterwards. Nothing is spawned and nobody is moved until every participant and every destination has been checked — so a cast with one person missing costs nothing rather than half-starting.

An emote ends for everybody or for nobody. Anyone moving, quitting or being hit ends it for the whole group, because half a handshake is one person shaking air.

Building your own /emote command#

Emote names are free text with spaces in them, so "Slow clap Steve" is either one emote or an emote and a player, and there's no separator to tell them apart. perform resolves that for you — longest leading run of words that names an emote wins:

@Override
public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) {
    if (!(sender instanceof Player player)) return true;
 
    EmoteResult result = rpai.emotes().perform(player, Arrays.asList(args));
    player.sendMessage(describe(result));    // your switch from above
    return true;
}
 
@Override
public List<String> onTabComplete(CommandSender sender, Command cmd, String label, String[] args) {
    List<String> options = new ArrayList<>(rpai.emotes().ids());
    options.addAll(rpai.emotes().castCandidates(sender, args));
    return options;
}

castCandidates is empty unless the words so far name an emote that takes a cast and there are slots left — so completing a solo emote offers nobody, and a duet stops offering once its partner is typed.

Stopping one#

rpai.emotes().stop(player);              // false if they weren't emoting
rpai.emotes().isEmoting(player.getUniqueId());

Refusing an emote on your own terms#

The library refuses an emote in the air and in spectator, because those break the emote itself. It has no opinion about arenas, minigames, regions, combat or whose turn it is — those are yours, and EmoteStartEvent is where you say so.

It used to refuse one in combat too. That's gone: being hit still ends an emote that is running, but whether a fight should stop somebody starting one is a gameplay rule, and this library doesn't get to guess the rules of a server it doesn't own. Cancel EmoteStartEvent on your own combat timer to bring it back.

@EventHandler
public void onEmote(EmoteStartEvent event) {
    if (inMatch(event.getLead())) {
        event.setCancelled(true);
        event.getLead().sendMessage("Not during a match.");
    }
}

Cancelling reaches the caller as Reason.CANCELLED with nothing attached — the reason belongs to whoever cancelled, so say it yourself as above.