Snapshot slices
The engine restores players, blocks and entities on its own. Everything else your game keeps in memory is invisible to it. Scores, the current phase, team assignments, round timers, a kill tally: none of that lives in the world, so none of it comes back on its own.
A SnapshotSlice is the seam where you hand that state to the recorder. Without one, a rewind produces the uncanny result where the arena is genuinely back at 3:20 but the scoreboard still says someone won.
The interface
Section titled “The interface”public interface SnapshotSlice {
/** Stable id for this slice within a recording, e.g. "match-score". */ String id();
/** Snapshots current state, or null to skip this tick. */ Object capture(ServerLevel level);
/** Re-applies a previously captured snapshot on rewind. */ void restore(ServerLevel level, Object snapshot);}capture is polled every tick. Results are change-deduped, so a slice that rarely changes costs very little memory even at a per-tick poll. On a rewind, the snapshot taken at or before the target frame is handed to restore.
The rule that makes deduping work
Section titled “The rule that makes deduping work”Your snapshot must be an immutable value (or a defensive copy) and must implement equals.
This is the one place where a mistake is quiet rather than loud. If you return a live mutable object, the recorder is holding a reference that keeps changing underneath it, and every historical frame ends up describing the present. If you skip equals, nothing ever compares equal, dedupe never fires, and a slice that changes once a minute is stored a thousand times.
A record is the natural fit, since it gives you both properties for free:
record ScoreSnapshot(Map<UUID, Integer> scores) { ScoreSnapshot(Match match) { this(Map.copyOf(match.scores())); // defensive copy, not the live map }}A complete slice
Section titled “A complete slice”public final class MatchRewind implements SnapshotSlice {
private final Match match;
private MatchRewind(Match match) { this.match = match; }
/** Binds a match's scores to whatever recording is running on its level. */ public static void bind(ServerLevel level, Match match) { Recording r = RewindRecorder.get(level); if (r != null) r.addSlice(new MatchRewind(match)); }
@Override public String id() { return "match-score"; }
@Override public Object capture(ServerLevel level) { return new ScoreSnapshot(match); }
@Override public void restore(ServerLevel level, Object snapshot) { match.applyScores(((ScoreSnapshot) snapshot).scores()); }}Wiring it up is then two lines at match start:
RewindRecorder.start(instanceLevel, RewindConfig.fromConfig(), hostUuid);MatchRewind.bind(instanceLevel, match); // scores now rewind with the worldChoosing what belongs in a slice
Section titled “Choosing what belongs in a slice”Include state that a player could observe and that does not live in the world: scores, lives, the phase machine’s current phase, round timers, team membership, per-player streaks.
Leave out anything already stored in the world, because the engine has it covered and you would be fighting it: block positions, entity positions, player health and inventory.
Leave out caches and derived values too. Restore the source of truth and let the derived value recompute, rather than restoring both and risking them disagreeing.
capture may return null
Section titled “capture may return null”Returning null skips that tick entirely. It is the right answer when the slice genuinely has nothing meaningful to record yet, for example before a match has started. It is not a way to throttle: dedupe already handles that, and it does it more accurately than a manual counter.
Restoring is not an event
Section titled “Restoring is not an event”restore is called during a scrub, potentially many times as the host drags the seek bar. Make it idempotent and cheap, and do not fire game events, play sounds, award anything, or broadcast messages from inside it. The host is scrubbing, not playing, and every one of those side effects will fire on every notch.