Skip to content

Plugin API (in-process)

Call the recorder in-process from a plugin on the same server: read recording state, tag timeline events, save clips, resolve death-cam links, and listen for recorder events, with no API key.

8 min read

What the in-process plugin API gives you

The in-process plugin API is what a plugin running on the same server as the ReplayCore recorder uses to talk to it directly, inside the same JVM. There is no network round-trip and no API key: your plugin holds a live object and calls it straight away.

From your own code you can read the live recording state, tag custom events onto the recording so they appear on the in-browser viewer's timeline scrubber, save on-demand clips of a player's recent gameplay, resolve a player's latest death-cam link, and listen for recorder lifecycle events.

It is the in-process companion to the REST developer API. The REST API, authenticated with an rc_live_ key, is for tools that run off the server; this API is for a plugin that runs on the server, where an API key would be redundant. Both recording lanes register the identical surface, so a plugin written against it behaves the same on modern (Paper or Folia 1.21 and newer) and on legacy (1.8.8).

Getting the API

Two steps. First, soft-depend on ReplayCore in your plugin.yml so your plugin loads after it (use depend instead if your plugin cannot function without it). Second, resolve the API once ReplayCore has enabled, or lazily on first use.

ReplayCoreProvider is the static entry point. Its single lookup, ReplayCoreProvider.get(), returns an Optional<ReplayCoreApi>: the live API when ReplayCore is present and enabled, or an empty optional when it is absent or has not yet enabled. Guard on that optional with isPresent() (or ifPresent and orElseThrow) so your plugin degrades gracefully when ReplayCore is not installed, rather than assuming the recorder is loaded.

Once you hold the ReplayCoreApi, every capability hangs off it. timeline() and recordingControl() are always present while ReplayCore is enabled. clips() and killReplay() return an Optional that is empty when that capability is not enabled on this server, so you negotiate capabilities without parsing version strings. apiVersion() reports the contract version (for example 1.1), which changes its major component only on a breaking change. That is versioned independently of the product release: it is not the same number as the SDK release tag you depend on in your build, nor the ReplayCore plugin's own version, so do not expect the three to move together.

plugin.yml, then resolve the API on enable
# plugin.yml
name: MyPlugin
version: 1.0.0
main: com.example.MyPlugin
softdepend: [ReplayCore]

# In your plugin, after ReplayCore has enabled:
# Optional<ReplayCoreApi> maybeApi = ReplayCoreProvider.get();
# if (!maybeApi.isPresent()) {
#     getLogger().info("ReplayCore is not installed; integration disabled.");
#     return;
# }
# ReplayCoreApi replayCore = maybeApi.get();
# getLogger().info("ReplayCore developer API " + replayCore.apiVersion() + " ready.");

Reading the recording state

recordingControl() returns a RecordingControlApi, a cheap read of whether the recorder is capturing right now. isRecording() is true while capture is active. currentTick() returns an OptionalLong holding the current recording tick (the anchor a timeline event is pinned to), and currentSession() returns an Optional<RecordingSession> identifying the live segment. When recording is stopped, isRecording() is false and both Optionals are empty.

RecordingSession exposes sessionId() (the same identifier the finished replay is published under, so you can store it and resolve the replay later through the REST API) and startedAt(). The snapshot carries no live recorder state, so it is safe to hold or pass to another thread.

Check whether capture is active
RecordingControlApi recording = replayCore.recordingControl();

if (recording.isRecording()) {
    long tick = recording.currentTick().orElse(0L);
    recording.currentSession().ifPresent(session ->
        getLogger().info("Recording " + session.sessionId() + " at tick " + tick));
}

Tagging a timeline event

timeline() returns a ReplayCoreTimelineApi. Its single method, boolean tagTimelineEvent(IntegrationBookmark), tags a custom event onto the live recording at the current tick. The event appears on the in-browser viewer's scrubber, rendered exactly like the events the bundled adapters emit, so an owner can jump straight to it.

A return of true means the bookmark was accepted onto the current tick. A return of false means recording is currently inactive, or the per-tick bookmark buffer was momentarily full. Neither blocks the server, so if the event matters and you got false, retry on a later tick.

If the bookmark's type is arena_start, arena_end, duel_start or duel_end, it also drives per-match session rotation in match mode: the same gate the bundled BedWars1058, MBedwars and Duels adapters use to seal one archive per match. A custom game mode can start a fresh recording session per round by tagging one of those four types as a match boundary. category_start and category_end are recorded onto the timeline like any other bookmark but do not drive rotation through this method: the built-in category chat-trigger designer rotates through a separate internal path that can see how many matching categories are currently running, which a tagged bookmark cannot express, so wiring bookmark-driven rotation to category types would re-cut the archive on every concurrent match rather than only the first and last.

Session rotation cuts the physical recording, so it can only represent one match boundary at a time on a server and is not a fit for several matches running at once. For that, represent each match as a logical scope instead of relying on rotation: the network-integration match API (matches() on the umbrella ReplayCoreApi, present when this server has network-integration scopes enabled) opens, updates and ends a tick-window scope per match over the one continuous recording, so beginning or ending a scope never starts, stops or cuts the archive, and dozens of scopes can be open on one server at once.

Mark a captured flag on the live recording
Map<String, String> metadata = new LinkedHashMap<>();
metadata.put("team", "red");

IntegrationBookmark bookmark = new IntegrationBookmark(
    "MyGameMode",                          // source (required)
    "objective",                           // type (required)
    IntegrationBookmark.Severity.WARNING,  // severity: INFO, WARNING or RED
    capturerUuid,                          // player UUID (optional)
    capturerName,                          // player name (optional)
    arenaId,                               // arena id (optional)
    "Captured the flag",                   // message (optional)
    metadata);                             // metadata, up to 16 entries (optional)

boolean tagged = replayCore.timeline().tagTimelineEvent(bookmark);
if (!tagged) {
    // Recording is inactive, or the per-tick buffer was momentarily full.
}

The IntegrationBookmark model

IntegrationBookmark is the immutable event you pass to the timeline API. source and type are required: if either is null or blank, construction throws. severity is one of INFO, WARNING or RED, and defaults to INFO when null. The remaining arguments are optional: player UUID, player name, arena id, message and a metadata map.

The model sanitises and bounds itself, so a malformed event can never corrupt the recording. Each bounded text field is trimmed, has control characters stripped, and is truncated to at most 128 characters. Metadata is capped at 16 entries, keys are normalised to lower case, and null keys or values are dropped.

IntegrationBookmark constructor arguments
source      required   identifies the producing plugin or system
type        required   event type; arena_start / arena_end / duel_start /
                       duel_end also rotate the recording session in match
                       mode; category_start / category_end are recorded but
                       do not rotate through this API
severity    optional   INFO, WARNING or RED (defaults to INFO)
playerUuid  optional   subject player UUID
playerName  optional   subject player name
arenaId     optional   arena or match id
message     optional   short human-readable description
metadata    optional   string-to-string map, up to 16 entries

Limits: bounded text fields are trimmed and truncated to 128 characters;
metadata is capped at 16 entries with keys normalised to lower case.

Saving clips on demand

clips() returns an Optional<ReplayCoreClipApi>, present only when clips are enabled and cloud upload is configured on this server. A clip is a wall-clock window over the one continuous recording: no new recording is started. The surface mirrors the in-game clip commands and a plugin cannot bypass their limits.

saveClip(UUID requester, UUID target, Duration window) saves a fixed look-back, for example a reported player's last 30 seconds from a staff tool. startClip and stopClip bracket a window of unknown length, for example opening a marker at round start and closing it at round end. The requester is who the clip is attributed to and who receives the watch link; the target is the player being clipped, who must be online. The window is clamped to the smaller of your request, the target's online time this session, and the server's clip cap, and the same per-requester cooldown as the command applies.

Each call returns immediately with an outcome enum: branch on it rather than assuming success. saveClip and stopClip return SAVING (accepted, the link is on its way to the requester), ON_COOLDOWN, TOO_SHORT, NO_RECORDING, BUSY (the pipeline is momentarily saturated, retry shortly) or UNAVAILABLE; stopClip adds NO_MARKER (no open marker to close). startClip returns STARTED, ALREADY_OPEN, ON_COOLDOWN, NO_RECORDING or UNAVAILABLE. The watch link is minted off the main thread and delivered to the requester in-game.

Save a reported player's last 30 seconds
replayCore.clips().ifPresent(clips -> {
    // Attributed to the staff member; the target's last 30 seconds is saved.
    ReplayCoreClipApi.SaveResult result =
        clips.saveClip(staffUuid, reportedUuid, Duration.ofSeconds(30));

    if (result == ReplayCoreClipApi.SaveResult.SAVING) {
        // Accepted. The watch link reaches the staff member when it is ready.
    }
});

// For a window of unknown length, bracket it instead:
replayCore.clips().ifPresent(clips -> {
    clips.startClip(staffUuid, fighterUuid); // open a marker now, at round start
    // ... the round plays out ...
    clips.stopClip(staffUuid, fighterUuid);  // close it and save the window
});

killReplay() returns an Optional<KillReplayApi>, present only when the death-cam feature is enabled on this server. latestKillReplay(UUID playerId) resolves that player's most recent still-valid death-cam session, returning an Optional<KillReplay> that is empty when there is no recent death-cam or the last one has expired.

KillReplay exposes command() (a ready-to-run in-game command that opens the replay, for example /watch <id>), replayId(), and expiresAtMillis() (the epoch-millis after which the session token is no longer valid). Wire command() into your own death message or kill feed. The lookup reads an in-memory registry and never touches the recording hot path. The same data backs the bundled death-cam message and the PlaceholderAPI tokens.

Wire the latest death-cam link into a death message
replayCore.killReplay().ifPresent(deathCam ->
    deathCam.latestKillReplay(victimUuid).ifPresent(link -> {
        String watchCommand = link.command(); // e.g. /watch <id>
        // Drop watchCommand into your custom death message as a clickable line.
    }));

Listening for recorder events

Register a RecordingListener with registerListener(RecordingListener) to be notified of recorder lifecycle moments, and unregisterListener when your plugin disables. Both methods are default no-ops, so you override only the moments you care about. Registering the same instance twice has no additional effect.

Four events fire today. onRecordingStarted and onRecordingStopped each carry a RecordingSession snapshot (its sessionId, serverId, the integration it was started under, and startedAt). onRecordingStarted fires just after a new session begins. onRecordingStopped fires just after one ends, after which the session's replay enters cloud finalisation and its metadata becomes available through the REST API.

onAssetReady and onAssetFailed each carry a ReplayOperationResult and report the outcome of work opened through the match surface, once the cloud has confirmed it. This is how you learn that a match scope you closed has produced a playable asset, rather than polling for it.

Callbacks are observational only: they report what the recorder did and cannot alter or veto it. A listener that throws is isolated and logged by the recorder, and never stops the other listeners or the recording.

Observe recording start and stop
replayCore.registerListener(new RecordingListener() {
    @Override
    public void onRecordingStarted(RecordingSession session) {
        // A new recording session has begun.
        getLogger().info("Recording started: " + session.sessionId());
    }

    @Override
    public void onRecordingStopped(RecordingSession session) {
        // The session has ended and now enters cloud finalisation.
        getLogger().info("Recording stopped: " + session.sessionId());
    }
});
// Call replayCore.unregisterListener(listener) from your plugin's onDisable.

Thread safety

Call the API on the server's main thread. Every call here is a cheap, non-blocking operation: the recorder never blocks the server to read state, tag an event, save a clip or resolve a link, and none of these methods does network I/O on your thread. The heavier work (minting a clip's watch link, for example) happens off the main thread inside the recorder, so you can call straight from a normal event handler without scheduling.

onRecordingStarted and onRecordingStopped fire on the main thread, so those two callbacks may touch the Bukkit API directly. In return, they must return quickly and must not block: offload any slow or networked work, such as a web request, a database write or a webhook, to another thread yourself.

onAssetReady and onAssetFailed are different. They fire on the scope finalisation worker, not the main thread, because the outcome only becomes known once the cloud confirms it, long after the originating call returned. Touching the Bukkit API from either one is unsafe. Schedule that work back onto the main thread yourself with the scheduler. A listener that throws is isolated and cannot break finalisation for the others, but do not rely on that as error handling.

Bundled plugin integrations

If your server runs one of the supported plugins, the recorder tags the timeline for you with no configuration. The bundled match adapters cover BedWars1058, MBedwars and Duels, emitting events such as match start and end, bed breaks and final kills. The bundled anti-cheat adapters cover Vulcan and Grim, surfacing their flags onto the recording (see the Grim anti-cheat article for Grim setup, markers and troubleshooting). PlaceholderAPI is supported through a ReplayCore placeholder expansion (see the Kill-Replay placeholder API article for the tokens), and LuckPerms provides read-only permission mapping for replay access.

A server running those plugins gets meaningful timeline events for free, and your own IntegrationBookmark events sit alongside them on the same scrubber. Several of these integrations have their own setup article under the Integrations section. Lunar Client (Apollo) and Citizens are not yet supported.

Getting the types

The plugin API interfaces and models (ReplayCoreProvider, ReplayCoreApi, IntegrationBookmark and the rest) live in the uk.co.forgevector.replaycore.api.plugin package and ship in the open-source ReplayCore Java SDK at https://github.com/forgevector-software-limited/replaycore-java-sdk; import them from that package. The current SDK release is 1.6.0, published to JitPack as com.github.forgevector-software-limited:replaycore-java-sdk:v1.6.0 (the dependency snippet below). That is not the same number as the in-process contract version apiVersion() reports, which is versioned independently and changes only on a breaking change to this API.

Add the types as a compileOnly dependency: the ReplayCore plugin already provides these classes at runtime, so compiling against them (rather than bundling them) keeps the service lookup working and avoids relocation issues if you shade your plugin.

build.gradle (confirm the version tag in the SDK README)
repositories {
    maven { url 'https://jitpack.io' }
}

dependencies {
    // Provided at runtime by the ReplayCore plugin, so compile against it only.
    // The in-process plugin API ships from the v1.6.0 SDK release.
    compileOnly 'com.github.forgevector-software-limited:replaycore-java-sdk:v1.6.0'
}

Available today, and what is coming

Available today, in-process, on both lanes: reading the recording state, tagging timeline events with IntegrationBookmark, saving on-demand clips (save, start and stop), resolving death-cam links, opening and closing match scopes through ReplayCoreMatchApi, listening for the recorder lifecycle events above (recording start and stop, plus asset ready and asset failed), and the bundled match, anti-cheat, PlaceholderAPI and LuckPerms integrations. These are real and usable now, with no API key, because your plugin is already on the server.

ReplayCoreMatchApi is the in-process half of the network integration API. Reach it from ReplayCoreApi.matches(), or from Bukkit's services manager, and it gives you three calls: beginScope(BeginScopeRequest) opens a logical match over the continuous recording, updateScope(String, ScopeUpdate) applies participants joining or leaving and metadata changes, and endScope(String, EndScopeRequest) closes it. Each returns a CompletionStage. Opening a scope never starts, stops, rotates or cuts the physical recording, which is what lets several arenas run concurrent scopes on one server.

matches() returns an empty Optional when the surface is not available, and that is a normal condition rather than an error: a scope has nowhere to finalise to without an authenticated cloud connection, and the surface can also be disabled by the managed Network integration setting in Configuration Studio. Check the Optional before using it and degrade gracefully. Everything the scope API cannot do in-process, which is reading the catalogue, holding and releasing embargoed footage, and minting watch tickets, lives on the REST surface documented in the Network Integration API article.

The scope enums that ship in the same package, AssetKind, AssetRelationship, EventKind, ReleaseState and ReplayVisibility, are the shared vocabulary of that REST surface. No published in-process method currently accepts or returns them, so you cannot construct or observe one through ReplayCoreMatchApi today. Read them as reference values for the REST fields of the same name, and do not build an in-process code path that expects to receive one.

In-process catalogue queries are live. ReplayCatalogApi lets you list a player's replays and mint a watch ticket without leaving the server process; it is registered on the Bukkit ServicesManager on both lanes, so you obtain it with getServer().getServicesManager().load(ReplayCatalogApi.class) rather than through a ReplayCoreApi accessor. Still on the roadmap and not yet available: forcing or locking capture settings from code at startup (today those settings come from configuration only), and additional recorder lifecycle callbacks beyond the four above (for example clip-saved or timeline-tagged callbacks). Off the server, webhook delivery and aggregate analytics are REST endpoints rather than in-process calls. This page marks each item live once it ships; do not build against anything still listed as unavailable.