Rewind History Cache Hybrid Proxy Plan¶
Purpose¶
Build a server-authoritative rewind history cache that can later validate locally predicted projectile hits. The milestone should create reliable historical hitbox data and pooled server-only proxy collision components, but should not yet implement GAS ability integration, client projectile handoff, projectile catch-up simulation, damage application, AoE policy, or full skeletal hitbox sets.
The intended future use is:
- A client fires a predicted projectile and sends a fire request with a timestamp.
- The server accepts the request, spawns the authoritative projectile, and runs one catch-up validation from fire time to server now.
- During that catch-up window, the validator queries historical rewind frames, builds only nearby proxy hitboxes, runs Unreal sweeps/overlaps against those proxies, and maps proxy hits back to real actors/components.
- If no historical hit is found, the projectile is placed at its caught-up authoritative transform and then continues with normal server movement/collision.
The projectile should not keep re-simulating against the rewind cache every movement step after catch-up. The cache records continuously in the background, but validation queries should happen only when a system needs historical checks.
Current Project Fit¶
The current projectile path is compatible with this cache design.
UFireProjectileSpell::SpawnProjectile()is the legacy/fallback spawn path.UFireProjectileSpell::SpawnProjectileFromTargetData()consumes GAS target data carrying fire rotation and client fire time in server-world-time space.- Locally controlled clients spawn a non-replicated dummy projectile for immediate feedback; authority still spawns the real damaging projectile. The dummy keeps its Blueprint collision so it can run local predicted impact visuals, but it must never apply damage.
- The authority path uses deferred spawn, sets projectile flags/team ids/spec handle, then calls
FinishSpawning(). - Server projectile actor replication is a debug/transition option. The authoritative projectile still exists and resolves damage on the server, but production can disable projectile actor replication and replicate only impact/effect results after catch-up.
- When server projectile actor replication is enabled for debugging/transition, it should not be relevant to the owning shooter connection. The firing client keeps its local dummy for the projectile's visual lifetime instead of replacing it with the caught-up replicated server actor. Other clients can still receive the replicated server projectile.
ATankProjectile::BeginPlay()runs after those fields are set, so aURewindHitboxComponenton the projectile can register with the cache and see correct owner/team state.ATankProjectilealready has aUSphereComponentnamedSphere, which is a valid first-pass rewind source primitive.- After authoritative spawn,
ATankProjectile::RunInitialLagCompCatchup()can run one bounded catch-up pass from accepted fire time to server now.
Important integration rule: RegisterHitboxComponent() must record an immediate first snapshot at the current server time. Without this, fast projectile-vs-projectile cases can miss a newly spawned projectile until the next configured snapshot cadence.
Must Do / Must Check¶
Rewind Component Setup¶
- Every historical target must have a
URewindHitboxComponent. SourcePrimitiveNamemust point to a supported live primitive on the same actor class/default actor:USphereComponentorUCapsuleComponent.- Projectile classes only need a rewind component when they should be historical targets for other lag-compensated queries. A projectile does not need a rewind component to run its own initial catch-up.
bProjectileHitboxonly controls whether rewind queries can include this hitbox as a projectile target. It does not enable or disable the projectile's normal UE collision/overlap path.- Team ids are cached by the rewind component and should be verified on spawn for actors whose team is assigned during deferred spawn.
Projectile Catch-Up Setup¶
SpawnProjectileFromTargetData()must fail when required target-data fields are missing, including fire time and rotation.- Server projectiles spawned with client fire time must suppress native overlap processing until the initial catch-up exits.
- Initial catch-up must run once, before normal runtime collision resumes.
- Catch-up substeps are capped, currently at 8, and tiny catch-up windows are skipped.
- Catch-up movement uses gravity-aware projectile movement deltas and 3D query segments. The broadphase/candidate step is only an optimization; final validation still sweeps against UE collision.
- Stationary live-world collision, such as world static, world dynamic blockers, and destructibles, must still be swept during catch-up because those actors do not need rewind history.
Remote Projectile Trail Masking¶
- Projectile Blueprint trail Niagara components used for lag-comp visual masking must have
Auto Activate=false. - The projectile's rewind component
BulletTrailNiagaraComponentNamemust select the actual trail Niagara component on the projectile class/default actor. - Local prediction dummy projectiles explicitly activate the selected trail component in C++ during
BeginPlay(). - For non-owning clients that receive the replicated caught-up server projectile, C++ reuses that selected trail component. It rewinds/advances the component from original fire location to caught-up location, then attaches it back to the replicated projectile so the same Niagara lifetime continues.
- The firing client keeps its local dummy projectile and should not receive/replace it with the caught-up replicated server projectile.
- If a projectile has no selected trail Niagara component, the catch-up visual mask is skipped; collision and damage behavior are unaffected.
Core Architecture¶
Authority Only¶
URewindHistorySubsystem runs only on authority.
It must not tick or register hitboxes on clients. It should also filter world type correctly:
- allow
EWorldType::Game - allow
EWorldType::PIE - reject editor preview, editor, inactive, invalid, and client worlds
Use UTickableWorldSubsystem if available/preferred in UE 5.6, or implement FActorComponentTickFunction/FTickableGameObject style ticking with proper world and stat guards.
Auto-Registering Hitboxes¶
Relevant actors do not manually register themselves. A URewindHitboxComponent handles registration.
Actors that need rewind support must have a URewindHitboxComponent added in C++ or Blueprint and must assign SourcePrimitive.
This works for:
- players
- minions
- deployables
- shields
- server-authoritative projectiles, only when they should be historical targets for other rewind queries
It does not automatically apply to every actor class unless the component is actually added and configured. Client-only predicted visual projectiles should not register.
Clarification: the projectile performing its own initial server catch-up does not need a URewindHitboxComponent. That projectile replays itself forward from accepted fire time using its movement component settings and collision shape. Rewind components are needed on actors/components that must be queried as historical targets. Add a rewind component to projectile classes only for projectile-vs-projectile lag compensation, where another projectile needs to see this projectile's past location.
Supported Source Primitives¶
First pass supports:
USphereComponentUCapsuleComponent
Unsupported primitives skip snapshots and may log once in non-shipping builds.
Cached Shape Data¶
Frames should store both exact proxy data and cheap broadphase data.
For spheres:
- world location
- world rotation
- scaled sphere radius
- 2D center from world XY
- 2D broadphase radius = scaled sphere radius
For capsules:
- world location
- world rotation
- scaled capsule radius
- scaled capsule half height
- 2D center from world XY
- broadphase radius = conservative bounding sphere radius for candidate rejection, currently
max(scaled capsule radius, scaled capsule half height)
Broadphase data is only a candidate filter. Final hit validation still uses pooled UE sphere/capsule proxies.
Data Model¶
Shape Type¶
enum class ERewindShapeType : uint8
{
Sphere,
Capsule
};
Snapshot Frame¶
Frame data should be keyed by URewindHitboxComponent, not actor. One actor may have multiple rewind hitboxes.
struct FRewindHitboxFrame
{
double ServerTime = 0.0;
TWeakObjectPtr<AActor> SourceActor;
TWeakObjectPtr<URewindHitboxComponent> SourceHitboxComponent;
TWeakObjectPtr<UPrimitiveComponent> SourcePrimitive;
FName HitboxName;
FGameplayTag HitRegionTag;
ERewindShapeType ShapeType = ERewindShapeType::Sphere;
FVector Location = FVector::ZeroVector;
FQuat Rotation = FQuat::Identity;
float Radius = 0.0f;
float HalfHeight = 0.0f;
FVector2D BroadphaseCenter = FVector2D::ZeroVector;
float BroadphaseRadius = 0.0f;
bool bDamageable = true;
bool bBlocking = false;
int32 MatchTeamId = INDEX_NONE;
uint32 GameplayStateFlags = 0;
};
Do not include bAlive for this milestone. Actor/component lifecycle, unregister, and weak pointer pruning define validity.
History¶
Use fixed-size ring buffers per hitbox component.
struct FRewindHitboxHistory
{
TWeakObjectPtr<URewindHitboxComponent> SourceComponent;
TArray<FRewindHitboxFrame> Frames;
int32 HeadIndex = 0;
int32 NumValidFrames = 0;
};
Initial values:
HistoryBufferSeconds = 0.250MaxRewindSeconds = 0.250MaxFutureToleranceSeconds = 0.025MaxInterpolationGapSeconds = 0.100
Team Handling¶
The project uses two team ids, not one:
MatchTeamId
URewindHitboxComponent resolves both values through UBrickleafTeamSubsystem while recording each snapshot. The subsystem reads the canonical IDs directly from the resolved ATankPlayerState, so snapshots require no team cache or change-delegate bindings and always capture the current authoritative values.
Cadence¶
Each URewindHitboxComponent should support configurable snapshot cadence.
Suggested enum:
enum class ERewindSnapshotCadence : uint8
{
OwnerNetUpdateFrequency,
FixedHz,
EveryServerTick
};
Defaults:
- default cadence:
OwnerNetUpdateFrequency - fixed Hz default if selected:
60.0 - minimum interval clamp to avoid pathological high rates
- maximum interval clamp to avoid unusable history gaps
OwnerNetUpdateFrequency means derive interval from Owner->NetUpdateFrequency. It does not mean hook Unreal's internal replication send event.
The subsystem keeps per-component accumulators and records snapshots when each component is due.
RegisterHitboxComponent() must immediately record one first snapshot. Normal cadence continues after that.
Queries rely on interpolation between frames.
Historical Lookup¶
GetHitboxFrameAtTime() interpolates between adjacent recorded frames.
Interpolation:
- location: lerp
- rotation: slerp
- radius: lerp
- half height: lerp
- broadphase center/radius: derive from interpolated location/radius or lerp directly
- booleans: nearest-frame
- team ids: nearest-frame
- gameplay flags: conservative policy later; for this milestone nearest-frame is acceptable if used only for debug/metadata
Out-of-range behavior:
- query older than oldest frame: fail
- query newer than newest frame: use latest only within
MaxFutureToleranceSeconds - frame gap larger than
MaxInterpolationGapSeconds: fail - query older than
MaxRewindSeconds: fail
Query Filtering¶
Filtering should run before proxy activation.
Suggested filter:
struct FRewindQueryFilter
{
TWeakObjectPtr<AActor> IgnoreActor;
int32 IgnoreMatchTeamId = INDEX_NONE;
bool bIgnoreSameMatchTeam = false;
bool bOnlyDamageable = true;
bool bIncludeBlockingHitboxes = true;
bool bIncludeProjectileHitboxes = false;
bool bIncludeActorHitboxes = true;
bool bUseSpatialFilter = false;
FVector2D QueryCenter = FVector2D::ZeroVector;
float QueryRadius = 0.0f;
};
Spatial filtering must expand by hitbox broadphase radius:
DistanceSquared(Frame.BroadphaseCenter, QueryCenter) <= Square(QueryRadius + Frame.BroadphaseRadius)
For projectile segment checks, use closest-point-on-segment distance in 3D against ProjectileRadius + Frame.BroadphaseRadius. The current catch-up path feeds the gravity-simulated substep segment into this candidate filter, so vertical separation can reject historical proxy candidates before proxy activation.
Hybrid Proxy Query Flow¶
The proxy path should not build historical proxies for every rewindable hitbox.
Per projectile catch-up substep:
- Compute
Tmidfor the substep. - Advance the projectile segment with the same gravity-influenced movement math used for authoritative catch-up.
- Build 3D query segment/bounds from the simulated projectile segment start/end and projectile radius.
- Ask the rewind subsystem for candidate frames at
Tmid. - Apply owner/team/category filters.
- Apply 3D segment broadphase filtering against historical frame location and conservative broadphase radius.
- Interpolate only candidates that pass.
- Activate pooled UE proxies only for candidates.
- Run
SweepMultiByChannel()or overlap against the dedicated lag-comp channel. - Map hit proxy components back to source metadata.
- Resolve gameplay policy outside the cache system.
- Clear active proxies by disabling them and clearing active metadata; do not destroy pooled proxy components.
This gives most of the performance benefit of candidate filtering while avoiding false positives from purely planar overlap. Pooled proxies are still only created after candidates pass the segment bounds.
Movement catch-up and rewind hit detection are separate decisions. All current projectiles use gravity for live movement, so movement catch-up advances projectile position and velocity with gravity to place the authoritative projectile at the correct post-catch-up transform. The current implementation also uses the gravity-influenced 3D substep segment for rewind proxy candidate filtering and UE sweeps, so non-bounce and bounce projectiles share the same vertical path during catch-up.
Historical proxies are only for rewind-managed hitboxes whose positions may differ from current server world state. Catch-up must also sweep the live world for non-rewound collision such as WorldStatic, static WorldDynamic, and Destructible objects. These objects do not need historical simulation if they are stationary, but they still need to be considered during every catch-up substep so projectiles cannot pass through walls, blockers, or destructibles during the rewind window.
Stationary minion buildings are neutral destructible/ASC actors and should stay out of the rewind cache unless they become movable. Their team ids may remain INDEX_NONE; this means any projectile can damage them, which is currently intentional. They are handled by normal live-world collision during catch-up and by normal projectile movement after catch-up.
Important live-world sweep finding: catch-up live-world validation currently uses an object-type sweep for WorldStatic, WorldDynamic, and Destructible. This is broader than the projectile's normal Blueprint collision profile. If a wrapper/placement actor has any primitive component whose object type is included in that sweep, the catch-up path can return the wrapper as Hit.GetActor() even when the normal projectile overlap path would hit only the child building actor. The fix is to make wrapper collision/object type ineligible for the catch-up live-world sweep, or later replace the broad object-type sweep with a query that mirrors projectile collision policy more exactly. Do not add automatic "find a child combat actor" resolution in projectile code; collision configuration should remain the source of truth.
Current gameplay override: static world actors can opt out of lag-comp live-world catch-up by adding the actor tag LagCompIgnore. ATankProjectile::RunInitialLagCompCatchup() skips live-world hits whose Hit.GetActor() has this tag before resolving impact/damage. This tag is intended for static world objects/wrappers that should not participate in lag-comp catch-up despite being returned by the broad live-world sweep. It does not affect rewind hitbox registration or historical proxy queries; actors with rewind components should use rewind/query policy instead.
Proxy System¶
Use a proxy pool. Do not spawn/destroy proxies during validation.
Suggested setup:
- one internal non-replicated host actor, e.g.
ARewindProxyHostActor UPROPERTY()references for the host and pooled components so GC cannot collect them- separate pools for spheres and capsules
- active proxy list for fast clear
ClearHistoricalProxies() means disable currently active components, reset active counts, and clear per-query metadata. The pooled sphere/capsule components remain allocated and are reused by later substeps or later projectile queries.
Proxy requirements:
- server-only
- non-replicated
- hidden in game
- query-only when active
- collision disabled when inactive
- no physics
- no generated overlap events
- no normal gameplay collision responses
- dedicated object/channel, e.g.
LagCompHitbox
The project currently has custom channels through GameTraceChannel7; GameTraceChannel6 is already named Query. Add a dedicated LagCompHitbox channel instead of reusing Query.
Define a project macro in Metal_terra.h, for example:
#define COLLISION_LAG_COMP_HITBOX ECollisionChannel::ECC_GameTraceChannel8
Proxy Metadata¶
Use a stable hot-path key such as TObjectKey<UPrimitiveComponent> or raw pointer while the proxy pool owns the components.
struct FLagCompProxyMetadata
{
TWeakObjectPtr<AActor> SourceActor;
TWeakObjectPtr<URewindHitboxComponent> SourceHitboxComponent;
TWeakObjectPtr<UPrimitiveComponent> SourcePrimitive;
FName HitboxName;
FGameplayTag HitRegionTag;
ERewindShapeType ShapeType = ERewindShapeType::Sphere;
double HistoricalTime = 0.0;
bool bDamageable = true;
bool bBlocking = false;
int32 MatchTeamId = INDEX_NONE;
};
Avoid TMap<TWeakObjectPtr<UPrimitiveComponent>, ...> for metadata lookup.
Projectile Lifecycle Expectations¶
Current ATankProjectile has PrimaryActorTick.bCanEverTick = false and uses UProjectileMovementComponent plus overlap events.
The future lag-comp integration should not turn projectile lifetime into constant rewind queries.
Recommended lifecycle:
- Server receives/accepts fire request.
- Server spawns projectile with deferred spawn.
- Server sets team ids, damage spec, movement settings, and future fire timestamp data.
- If the projectile has client fire time, mark native overlap handling as suppressed before
FinishSpawning(). - Projectile finishes spawning.
- If this projectile class has a rewind component because it should be a historical target, that component registers and records an immediate first snapshot.
- Server runs one initial catch-up validation for this projectile from client fire time to current server time.
- During catch-up, temporarily suppress normal live overlap handling or mark the projectile as in catch-up to avoid duplicate hit resolution.
- If catch-up hits, resolve impact/damage and end projectile as appropriate.
- If catch-up does not hit, place projectile at its caught-up transform and let normal server movement/collision continue.
After step 10, the projectile should not keep querying the rewind cache every movement step. It may still be recorded by the cache if it has a rewind hitbox component and projectile-vs-projectile support is enabled. A projectile without a rewind component can still perform its own catch-up and hit rewound actors, live-world blockers, and stationary destructibles.
Close-range spawn-overlap finding: when a server projectile spawns already overlapping a minion building, native OnSphereOverlap can fire during/just after FinishSpawning(). If initial catch-up then runs, substep 0 can hit the same actor again and apply a second impact/damage from a single ability activation. The current design suppresses native server overlap handling until initial catch-up has completed for projectiles spawned with client fire time. This suppression must clear on every catch-up exit path.
Remote visual masking: non-owning clients may receive the replicated server projectile only after it has already been caught up. To avoid a projectile appearing from nowhere, projectile Blueprints that opt into the mask should keep their trail Niagara component Auto Activate=false. The replicated projectile then uses the configured BulletTrailNiagaraComponentName component, advances it through the catch-up path, and attaches that same component back to the live projectile instead of spawning a second transient trail.
Projectile-Vs-Projectile Policy¶
Projectile-vs-projectile rewind should only consider server-accepted projectiles that already exist in authoritative history.
If projectile A is validated before projectile B's fire request is accepted by the server, A should not be retroactively revalidated when B later arrives. B can collide with A if A is already accepted and present in history at B's query time.
Default policy:
Projectile-projectile collisions are authoritative only against already-accepted historical projectiles. Late-arriving projectiles do not invalidate previously accepted projectile simulations.
Full rollback/replay of prior accepted projectiles is out of scope and should not be added unless projectile-vs-projectile interactions become central enough to justify the complexity.
Public API Sketch¶
class URewindHistorySubsystem : public UWorldSubsystem
{
public:
void RegisterHitboxComponent(URewindHitboxComponent* HitboxComponent);
void UnregisterHitboxComponent(URewindHitboxComponent* HitboxComponent);
bool GetHitboxFrameAtTime(
URewindHitboxComponent* HitboxComponent,
double QueryServerTime,
FRewindHitboxFrame& OutFrame
) const;
bool GetAllHitboxFramesAtTime(
double QueryServerTime,
const FRewindQueryFilter& Filter,
TArray<FRewindHitboxFrame>& OutFrames
) const;
bool BuildHistoricalProxiesAtTime(
double QueryServerTime,
const FRewindQueryFilter& Filter,
const FRewindProxyQueryBounds& QueryBounds,
TArray<UPrimitiveComponent*>& OutProxyComponents
);
void ClearHistoricalProxies();
bool TryGetProxyMetadata(
UPrimitiveComponent* ProxyComponent,
FLagCompProxyMetadata& OutMetadata
) const;
};
BuildHistoricalProxiesAtTime() should be candidate-driven through QueryBounds. Avoid an API shape that encourages building proxies for the whole world.
Debug Requirements¶
Add debug support early:
- draw historical sphere/capsule proxies
- draw 2D broadphase circles
- draw source actor/component name
- draw timestamp used
- show active proxies
- show oldest/newest cached time per component
- show lookup and candidate rejection reason
- dump history for one component
Debug functions should be safe in authority-only contexts and should not create replicated state.
Testing¶
Milestone tests:
- Add
URewindHitboxComponentto a character and assign character capsule. - Confirm server auto-registration.
- Confirm clients do not register.
- Confirm immediate first snapshot on registration.
- Confirm snapshots record at configured cadence.
- Confirm capsule snapshots contain world location, rotation, radius, half-height, and 2D broadphase values.
- Add a sphere source and confirm sphere snapshots work.
- Add a server-spawned projectile sphere and confirm it registers after
FinishSpawning(). - Confirm the projectile's first snapshot sees the correct
MatchTeamId. - Query historical time and confirm interpolation works.
- Confirm out-of-range, future tolerance, and large-gap queries fail safely.
- Confirm 2D broadphase excludes distant hitboxes before proxy activation.
- Confirm only candidates activate proxies.
- Confirm sphere/capsule proxy type and dimensions match source frames.
- Confirm proxy metadata maps back to source actor/component.
- Confirm proxies do not replicate.
- Confirm proxies do not generate gameplay overlap events.
- Confirm proxies do not affect normal gameplay collision.
- Confirm
ClearHistoricalProxies()disables active proxies. - Confirm destroyed actors/components/projectiles unregister or are pruned safely.
- Confirm team cache updates from
UBrickleafTeamSubsystemdelegates.
Future TODOs After Cache Milestone¶
Client Predicted Projectile Handoff¶
- Send fire rotation and a client fire timestamp through the existing GAS target-data flow.
- Use server world time synchronized through
AGameStateBase::GetServerWorldTimeSeconds(). - Do not send a fire transform for the current projectile path; authority derives spawn location from the validated server-side combat socket.
- Keep the firing client's local dummy for the projectile's full visual lifetime. Do not replace it with the caught-up replicated server projectile.
- If server projectile actor replication is enabled, filter owner relevancy so only non-owning clients receive that debug/transition server projectile.
- For non-owning clients, mask the delayed replicated projectile by advancing the selected Niagara trail component from the original fire point to the caught-up projectile location, then continuing that component as the live projectile trail.
- Projectile trail Niagara components that use this path must default to
Auto Activate=falsein Blueprint. C++ intentionally does not mutate auto-activation at runtime; it activates the selected component for local prediction dummies and when it plays the remote catch-up mask. - Add projectile fire sequence/id data so duplicate or late RPCs can be handled predictably.
Initial Catch-Up Simulation¶
- Run the one-time server catch-up path after projectile spawn when target data contains a valid fire timestamp.
- Substep from accepted fire time to current server time.
- For each substep, advance the projectile with gravity-aware movement, query the rewind cache at the midpoint, build proxies only for candidates near the 3D substep segment, and sweep projectile shape through historical proxies.
- If a hit is found, resolve hit and stop catch-up.
- If no hit is found, place projectile at caught-up transform and enable normal live movement/collision.
- Future: replay full historical bounce/deflection response for thrown or multi-impact projectiles. Current impact-count handling can decide when final impact should stop catch-up, but a complete bounce replay needs to update trajectory from each historical impact normal/contact instead of continuing along the original segment.
Normal Projectile Runtime¶
- Ensure normal runtime does not keep querying rewind every movement step.
- Use current
UProjectileMovementComponentand live collision for post-catch-up projectile life. - Suppress native overlap handling before initial catch-up for server projectiles spawned with client fire time, and clear that suppression on every catch-up exit path.
- Keep a post-impact guard so a projectile whose
CurrentImpactCount >= TargetImpactCountcannot apply another native overlap or catch-up hit. - Keep live-world catch-up collision configuration explicit. Stationary destructibles should be hit through live sweeps, but wrapper/placement actors must not use object types included by the catch-up sweep unless they are intended damage targets.
- Static world actors that must be excluded from catch-up despite their collision object type can use actor tag
LagCompIgnore. Prefer fixing collision profiles/channels when practical; use the tag as an explicit gameplay override.
Damage And GAS Integration¶
- Convert proxy metadata hits into real target actor/component decisions.
- Reuse current team/friendly-fire policy from
ATankProjectile. - Apply damage through existing GAS spec flow.
- Preserve source object/kill credit behavior from
FireProjectileSpell.
Projectile-Vs-Projectile¶
- Decide which projectile classes opt into rewind hitboxes.
- Store authoritative projectile spawn/fire time and destroyed time.
- Query only already-accepted projectile histories.
- Do not retroactively invalidate previously accepted projectile validations in the first version.
AoE Policy¶
- Define whether AoE uses historical positions, current positions, or hybrid rules.
- Keep AoE out of the first cache milestone.
Full Hitbox Sets¶
- Add multiple hitbox components per actor for detailed regions.
- Add skeletal/socket-driven hitboxes only after the capsule/sphere cache is stable.
- Keep actor/component auto-registration model.
Profiling And Optimization¶
- Profile proxy activation counts, UE sweep counts, and candidate rejection rates.
- Tune
HistoryBufferSeconds, cadence, and query broadphase sizes. - Consider batching proxy builds for multiple projectiles with similar query times only if profiling shows it is needed.
- Consider spatial grids or buckets if rewindable actor counts grow beyond simple candidate loops.