Add a custom replay event
Advanced integration · Shared payload on recording and viewing machines Use a custom recordable when a game-specific event needs to be stored and replayed, not merely shown live in the integration’s UI. A complete feature spans capture, serialization, and rendering; implementing only one side does not make a recording portable.The data path
The same custom data contract must be available on the recording and viewing machines. Keep shared payload/codec code independent ofapi-player and api-recorder when both sides need it; put renderer/capture bindings in the relevant integration modules.
Transient versus durable
Recordable is the marker for an event. StatefulRecordable adds apply(WorldState) for a mutation that participates in timeline folding.
- A transient announcement, particle burst, or one-off sound is typically emitted during normal playback.
- A durable entity/block change must update the accumulated scene so pause/seek/resync reproduce the target state.
Start with a transient event. A milestone announcement is a small end-to-end example. Persistent scene changes also need correct folding and resynchronization when viewers seek.
Create a small payload
Let’s record an announcement when the game enters a new round. Start with an immutable payload shared by recording and playback integrations:Teach Reverse how to save it
Create a symmetric codec in your shared integration code:RecordableService:
codecs is the borrowed runtime service. Register once before emitting or loading this event. The complete Milestone example shows separate recorder/player binding helpers.
Its codec ID 1000 is an example allocation, not a reserved extension range. Check the actual registry and coordinate a collision-free ID across all installed extensions. Register before your recording can emit the event and before replay files containing it can be loaded.
Unknown codec IDs are a hard load failure. Never ignore an unknown record: without its payload layout, the decoder cannot know where the next event begins.
Insert it into the match
When the round starts, append the event to the recording session your match owns:Render it for viewers
On the player runtime, register the renderer:Codec design
RecordableCodec.of(reader, writer) is suitable for small explicit schemas. Use the corresponding ReplayDataInput/ReplayDataOutput primitives in the same order and with the same signedness/units.
Avoid Java object serialization, Bukkit entity handles, and protocol-specific types in persisted data. Validate counts/ordinals before allocating collections or indexing enum arrays. An invalid payload must fail, not return a plausible but misaligned partial replay.
Payload/ID changes require coordinated extension releases on both recording and playback machines. Do not assume an earlier recording can be decoded after changing your custom event layout. Follow the release’s recording-compatibility notes as well as your own extension’s versioning policy.
Use the session context for entity effects
The renderer receives only the recordable and the activePlaybackContext; use its viewer collection and mappings for the current session.
For entity effects, map recorded IDs through the context. Do not send the original captured entity ID directly to viewers, or reuse one session’s remapped ID in another session.
The public player adapter describes the protocol-neutral playback contract. Keep saved data separate from packet-library types, and use the renderer/context contracts for ordinary custom-event playback.
Durable state and playback policies
Keep folding protocol-neutral. For example, a durable label belongs to an entity incarnation:EntityRecordable, StatefulRecordable, EntityState and WorldState are in the shared API
(com.marioded.reverse.api.recordable and com.marioded.reverse.api.state). Extension values
must be immutable; the state retains them by reference. getExtension/removeExtension return
null for absence. setExtension rejects null and validates the value against its class key.
Entity replacement discards the old incarnation’s extensions; world reset clears world extensions.
These maps are folded state, not a new persistence format: register your payload codec as usual.
On the player, register the renderer and the policy before creating sessions:
com.marioded.reverse.api.player.renderer.RecordablePlayback and
com.marioded.reverse.api.player.context.PlaybackContext/PlaybackProjection. Policies must
be stateless: do not retain contexts, projections, players or session state in them. All callbacks
run on the main thread, with one recipient per context. Callback state is read-only; only
apply mutates folded state. projection.emit renders directly, without folding or recursively
calling the policy.
The core withholds forward callbacks until terrain is ready. On seek, explicit terrain resend,
or distance reentry it emits accepted absolute spawns, equipment and motion for the selected
batch before calling entity restore hooks; selected related entities are already known. Generic
synchronization hooks follow, then world restore hooks run after the complete base scene. restoreWorld
and clearWorld provide the same pattern for values stored on WorldState. Clear hooks receive
outgoing state before mappings are retired, including when a lifecycle fold already removed
the entity. Seek clears before resetting state. For a terrain resend, custom clear hooks run
synchronously when that recipient becomes pending, before terrain retrieval starts, while the
folded values still describe its displayed custom output. Native entity knowledge and mapping
timing are unchanged. Playback can continue folding while the recipient waits; completion restores
the then-current state without repeating custom clear hooks. Coalesced requests clear newly pending
recipients only. Never-projected pending viewers are not cleared. Failures remove that recipient
rather than restoring it; healthy recipients still complete.
Do not mutate folded state or the session cursor from callbacks. A seek requested through
PlaybackService during clear is deferred until the whole clear pass and its removal bookkeeping
finish, so every distinct policy can clear its outgoing values. The last requested target wins,
including the current cursor. A failed pass discards the deferred seek; stop/departure take priority.
This deferral applies to cleanup, not an arbitrary queue of lifecycle operations.
For a custom spatial event, call projection.reconcileEntity(entityId) after folding; emit the
original movement only if it was known before and remains known afterwards, so an entrant does
not receive its delta twice. The core invokes synchronizeProjection once after that recipient’s
complete membership-transition batch; the built-in policy handles passengers there. For custom
lifecycle events override retiredEntityIds(event) with original IDs; the core processes these
once session-wide, even for pending viewers, before forward. Retirement also triggers generic
synchronization for affected ready recipients. isBaseline() declares silent pre-history folded
even beyond a seek target, never forwarded during normal play.
Bindings match exact classes, not supertypes. Register the same policy object for several
classes when they share restore/clear behavior: those hooks visit each distinct policy identity
once in first-registration order. Each session freezes its bindings; replacement affects only
new sessions. Without a binding, an entity record emits only for known subjects and other
records emit directly; no custom lifecycle or spatial behavior is guessed. During restore/clear,
reconcileEntity returns false without doing work to prevent nested restoration. Do not retain
the projection or manipulate terrain readiness or shared mappings yourself.
PlaybackProjection exposes only emit, isEntityKnown and reconcileEntity. Override
prepareProjection for setup before each spawn batch (including a full projection with zero visible
entities), and synchronizeProjection for relationships derived after a completed transition batch.
These hooks run once per distinct policy identity, not per entity; ordinary metadata/equipment updates
do not trigger them. Built-in teams and passenger relationships use the same hooks as extensions.
Session mutation and live state/map reads are main-thread-owned, not concurrent APIs. getViewers()
returns an immutable iteration snapshot. projectedEntities(viewer) is the single service-owned map
of known original IDs to outgoing entity incarnations; integrations may read it in hooks but must
not mutate it. During a resend its keys/references stay intact until native removal at completion,
even though custom output has already been cleared at admission. No separate mutable known-ID set
exists.
Correct coordinate and state conventions
- Relative entity movement deltas use shorts in 1/4096-block units; they are not absolute coordinates.
- Rotation values carried alongside relative movement are absolute orientations.
- Packed block positions have signed Y semantics. Use
Vector3i.pack/unpack, not an unsigned& 0xFFFY extraction. - Block values are state strings, not saved global protocol state IDs. Resolve IDs only at the protocol encode/decode boundary.
- Metadata and equipment packets can contain partial updates; durable folding merges entries rather than replacing the complete set.
- A baseline is pre-history for touched block positions. It seeds an untouched value and is not itself a block-change effect to draw.
Built-in vocabulary
Reverse records supported block changes, entity activity, chat, particles, and world effects automatically. Sounds are recorded only when the integration declares them throughrecordWorldSound/recordSessionSound, because a clientbound sound packet does not reveal whether the sound was world-wide or personal. Add a custom event when your integration needs additional game-specific data, such as the milestone announcement above.
Use the public recordable and codec contracts documented here, and register your own types with the service. Keep custom IDs coordinated across extensions and insert into the intended session(s) rather than editing a captured event collection.

