Skip to main content

Record a match automatically

Runtime: recorder · Entry point: ReverseRecorder · Lifecycle calls: Bukkit main thread Your integration starts a session when the arena is ready and stops that session when the match ends. Keep the returned session ID in your match object so every teardown path stops the correct recording. If you are new to the API, follow Creating your first recording and Improving your recording first. This page collects the remaining lifecycle, selection, and capture details.

Connect capture to your match lifecycle

Discover ReverseRecorder, obtain its recorder service, and start a session when your arena is ready. Store the returned session ID in your match object. Stop that exact session when the match ends or is cancelled. The complete RecordingIntegration example contains the start/stop flow and persistence error handling. It does not depend on the player runtime.

Starting a session

The simplest call captures a world with no owners or text:
For player-facing recordings, specify owners and useful text instead:
Start on the Bukkit main thread. Validate user-facing metadata before calling. The start path seeds the initial terrain/entity scene before publishing the session and its start event. Setup failures throw synchronously and do not publish a half-created session. Owners are deduplicated to an immutable snapshot. Empty is allowed, but such a replay is not accessible to normal nonadmin catalog actors unless your application deliberately uses another policy.

Spatial and player selection

Use the canonical overload when a world contains several arenas or spectators:
There are three independent decisions:
  1. Bounds limit spatial capture. Null means the whole world selection.
  2. Participant UUIDs select players. Null means all in-scope players; an empty set selects no players. Nonplayer entities remain spatially selected.
  3. Owners control the finished replay’s catalog access, not capture admission.
Bounds use inclusive block coordinates. Continuous positions use min <= p < max+1. Inverted bounds throw. Terrain capture includes full-height payloads of chunk columns intersecting the horizontal box. Bounds and participant selection are session-only, not additional persisted replay fields. Movement updates only admitted entities. Do not rely on an arbitrary outgoing move of an unknown/outside entity to create membership, or on bounds to synthesize exit/re-entry tracking. Participant selection applies to initial player snapshots, chat, equipment, and player-spawn packet producers. A non-participant’s spawn is dropped before it can create entity membership, so membership-gated follow-up packets do not admit that player later. Selection belongs to the player described by the observation, not the player whose connection carries it. The packet listener visits all sessions for the channel holder’s current world; it does not discard the channel just because its holder is not a participant. A spectator can receive packets about selected players, blocks, or particles that still belong in a recording. RecordSession.getParticipantIds() exposes the immutable session selection (nullable for all players); it is not a persisted owner list or a packet-recipient filter.

Templates

Supply a template name when the base scene already exists in a terrain provider:
Null/trimmed-empty means no template. Nonempty templates skip runtime terrain capture. The player machine must resolve that template too. Template mode and nonnull bounds are mutually exclusive and throw before capture starts.

Several sessions in one world

Multiple recordings may observe the same Bukkit world. Each owns independent selection, ticks, terrain identity, and stop lifecycle. Use getSessionsForWorld(worldId) to inspect them; a single “session by world” variable is not sufficient to identify a particular match’s recording. ReverseRecorder.getSessionsForWorld(worldId) returns an immutable snapshot of every active session in that world. Use RecorderService.getSession(sessionId) and your stored session ID for deterministic lifecycle control. isRecording(worldId) tests whether any session records that world; these world queries take UUIDs, not World objects. RecorderService.getSessions() returns the coordinated immutable snapshot across all worlds. getSession(sessionId) is nullable when inactive. There is no recorder getSession(Player) lookup or getSessions(World) overload; world membership and ownership do not identify one recording to stop.

Stopping and publishing

stopSession accepts only a session UUID. Call on the main thread. Capture stops immediately, while persistence proceeds asynchronously. Other sessions recording the same world remain active.
Stopped is not yet saved. Publish a Watch link only after the save stage succeeds, and decide how your product should handle a degraded terrain result.
Callbacks have no main-thread guarantee. Schedule Bukkit notifications back to the server thread. awaitPendingSaves() is useful for controlled shutdown orchestration, but do not block normal ticks with join()/get() waiting for backend I/O.

Clips

A recording keeps every tick: there is no mode and no window, so any live session can export a clip. Export a trailing duration, or an explicit interval, of a live session as a standalone replay:
Both methods address the source by session UUID, so they are not limited to the caller’s own recording. saveClip reads two offsets from the session’s first tick: a ZERO start selects the recording’s first ticks, a null end selects the current tick, and an end past it is clamped. A start beyond the current tick selects the final tick alone. Both bounds are inclusive. Durations are converted with Math.round(offset.toMillis() / 50.0); negative offsets and an end tick before the start tick are rejected. A recent duration must round to at least one tick. An explicit 0–10 second interval therefore includes both endpoint ticks, whereas a ten-second trailing clip retains up to 200 ticks. Invoke either method on the Bukkit main thread. The current-tick cutoff is frozen when the request is accepted; the source recording keeps running. For a session that is no longer active, valid arguments return a stage containing StopResult.notRecording() with a null replay ID. Handle completion as described under saving above: event persistence failures complete exceptionally, while terrain failures produce a degraded result. Completion callbacks have no main-thread guarantee. The sliced events are rebased so the clip starts at tick 0, and the state the source had folded up to the first retained tick is re-emitted at tick 0, so the clip opens with the players, entities and blocks that existed before its window rather than a view of a world it no longer sees. The source terrain is copied under the clip’s own identifier, so the clip survives the source session’s later stop or deletion. A non-positive recent duration is rejected before anything is written. Clips get fresh replay UUIDs and emit the same RecordingSavedEvent/RecordingSaveFailedEvent persistence notifications as full recordings. They do not stop the source session or emit its stop event. Pending exports are included in awaitPendingSaves(). Stopping a session always saves the full replay. There is no mode that discards the buffer.

Adding your own timeline events

Register a matching codec on each runtime that must decode the custom event, and a renderer on the player. Insert through the recorder service for the intended session(s), not by mutating its immutable recordable snapshot.
insertIntoSession(sessionId, eventOrSupplier) targets one session. Use insertIntoSessions(sessionIds, supplier) with an Iterable<UUID> for a chosen set, or insertRecordable(worldId, eventOrSupplier) deliberately to broadcast to recordings in that world. Events are rebased to each session’s relative tick. A supplier runs inline at most once on the calling thread, outside captureLock, after target IDs are frozen; insertion rechecks active targets under that lock before tick sampling, duplicate detection, folding and append. Do not perform Bukkit reads in a supplier called by a packet thread. Stop unregisters the target and snapshots its events under the same lock, so a late supplier cannot append after stop. Spatial admission belongs to the producer, not generic insertion. The world overload broadcasts its supplied event unchanged to all active sessions in that world, without spatial clipping or participant selection. Custom integrations must select sessions explicitly from their bounds, participants and entity context before insertion. For a multi-position event, construct the correct subset separately per session, preserving metadata, order and duplicates without mutating the source; skip empty subsets. Do not pass a separate position to insertRecordable: that argument no longer exists. Built-in packet producers check raw origins before conversion/profile extraction and each raw multi-block absolute position before state-name conversion. Bukkit chat/equipment producers own their contextual selection. Baseline producers select active sessions and bounds, resolve state successfully, construct the event, then atomically mark first capture per session before insertion; they never mutate the supplied session snapshot. Custom pre-mutation baseline producers must follow the same admission order. Relative movement and flagged teleports retain their existing membership and relative-state semantics rather than being treated as absolute point events. Equal events at the same tick are deduplicated before folding and append; sound declarations are inserted directly, so repeated sound emissions stay distinct. See custom recordables for the full end-to-end contract.

Metadata/profile and packet integrations

The public ReverseRecorder facade exposes the metadata/profile provider services through getMetadataProviderService() and getProfileProviderService(); the recorder adapter itself is selected by configuration rather than by an API setter (see protocol adapters). Ordinary minigame integrations should use RecorderService; they do not need to replace the capture layer or access private plugin internals. Entity-admission queries support network-thread capture. isEntityRecorded(sessionId, entityId) queries one active session’s tracked entity membership. Sounds are recorded explicitly, never from packets. Reverse does not inspect clientbound sound packets: such a packet carries no information about whether the emission was a world broadcast or a sound sent to a single player, so a packet capture could only guess and would record personal sounds. Instead the integration declares the emission at its source:
  • recordWorldSound(worldId, soundName, position, volume, pitch) records one occurrence into every active session recording the world.
  • recordSessionSound(sessionId, soundName, position, volume, pitch) records one occurrence into one session.
Both sample the tick under the capture lock and rebase it against the session start, skip sounds outside the session’s recording bounds, and ignore unknown worlds, stopped sessions and negative ticks. Each call records one occurrence: two calls in the same tick record two occurrences. Nothing is deduplicated, so a sound repeated within a tick is preserved. Because the declaration is the only source, sounds the server emits on its own (mob ambience, block sounds, vanilla effects) are not captured unless the integration declares them, and a sound sent to a single player is never captured by the plugin. Existing packet membership and relationship checks remain unchanged; this policy does not add full entity entry/exit observation or synthesized re-entry. Absolute spawn and world-event filtering belongs to packet/Bukkit producers before insertion, and player-spawn producers admit only a session-selected participant. Relative movement and flagged teleports are not generic point events. During playback, sounds reach only the session’s viewers and the client applies attenuation; there is no fixed 16-block server-side cutoff.