PurrDiction

Predicted Modules

Non-mono bound predicted code for maximum flexibility

This is new functionality and currently only available on the dev branch. Once it's been tested, it'll be released fully.

This was introduced in 1.2.2-beta.4
Dynamic modules were introduced in 1.3.0-beta.27
In case you don't see the functionality, ensure you are at least on this version of PurrDiction

The PredictedModule system allows you to encapsulate specific game logic and state into reusable, self-contained units. Instead of writing a PredictedIdentity that handles singular logic like timers, inventory, health, and such all in one script, you can break these features down into individual modules.

Why use Modules?

  • Encapsulation: Keep logic and state (e.g., a Timer or Health system) isolated from other systems.
  • Reusability: Write a module once (like a ProjectileMovementModule) and drop it into any PredictedIdentity.
  • Network Efficiency: Modules have their own delta compression. If only one module changes, only that module's data is sent over the network.
  • Automatic History & Rollbacks: Modules automatically participate in the prediction rollback system, saving you from manually managing history buffers for every variable.

Predicted Modules

The PredictedModule system allows you to encapsulate specific game logic and state into reusable, self-contained units. Instead of writing a monolithic PredictedIdentity that handles movement, health, inventory, and abilities all in one script, you can break these features down into individual modules.

Why use Modules?

  • Encapsulation: Keep logic and state (e.g., a Timer or Health system) isolated from other systems.
  • Reusability: Write a module once (like a ProjectileMovementModule) and drop it into any PredictedIdentity.
  • Automatic History & Rollbacks: Modules automatically participate in the prediction rollback system, saving you from manually managing history buffers for every variable.

Performance note: A predicted module acts similar to a predicted identity. This means that it's another state, and another simulation to handle. Pros of this is that it adds flexibility and modularity easily. The con being that now your identity is handling multiple simulations and multiple states which can be heavier for performance on both CPU and bandwidth. It's about weighing re-usability and flexibility vs performance.
However, this is not heavier than having multiple predicted identities.


Implementation Guide

Creating a module involves two steps: defining the state and creating the module logic.

1. Define the State

Create a struct that implements IPredictedData<T>. This holds the data you want to sync and predict.

public struct HealthState : IPredictedData<HealthState>
{
    public int currentHealth;
    public int maxHealth;

    public void Dispose() { }
}

2. Create the Module

Inherit from PredictedModule<TState>. You typically override Simulate for logic and UpdateView for visuals.

public class HealthModule : PredictedModule<HealthState>
{
    // You can customize the constructor for custom needs
    public HealthModule(PredictedIdentity identity, int startingHealth) : base(identity) 
    { 
        currentState.currentHealth = startingHealth;
        
        // Updates the visual buffer to match the new current state immediately
        ResetInterpolation();
    }

    // logic: runs on fixed ticks
    protected override void Simulate(ref HealthState state, float delta)
    {
        // Example: Regen health over time
        if (state.currentHealth < state.maxHealth)
        {
            state.currentHealth++;
        }
    }
    
    public void ChangeHealth(int change) => currentState.currentHealth += change;

    // Visuals: runs every frame
    protected override void UpdateView(HealthState viewState, HealthState? verifiedState)
    {
        // Update UI or visual effects based on the interpolated viewState
        // Easiest to utilize events to communicate out of the module
    }
}

Using a Module

Modules constructed before the first simulation tick (typically inside LateAwake) are static. They live for the lifetime of the identity and are not state-tracked. Modules constructed during simulation are dynamic and replicate automatically.

To use a module, instantiate it within your PredictedIdentity. It will automatically register itself with the identity's prediction lifecycle.

public class PlayerController : PredictedIdentity
{
    private HealthModule _health;
    private TimerModule _timer; // Built-in example

    protected override void LateAwake()
    {
        base.Awake();
        // Create and register the modules
        _health = new HealthModule(this, 100);
        _timer = new TimerModule(this);
    }

    // You can now access public methods or properties of your modules
    public void TakeDamage(int amount)
    {
        _health.ChangeHealth(-amount);
    }
}

Dynamic Modules

Modules can also be constructed during simulation. When this happens, the module becomes part of the replicated state. Other peers reconcile the module's existence automatically through the same rollback path used for predicted state.

This is useful when a module's existence depends on gameplay events. For example, attaching a temporary BurnEffectModule when a player catches fire, then disposing it when the effect ends.

protected override void Simulate(MyInput input, ref MyState state, float delta)
{
    if (input.applyBurn && !TryGetModule<BurnEffectModule>(out _))
    {
        var burn = new BurnEffectModule(this);
        burn.StartFor(3f);
    }
}

Disposing a dynamic module is done by calling Dispose() on the module itself. The module is removed from the identity and torn down on every peer through the same reconcile path.

protected override void Simulate(MyInput input, ref MyState state, float delta)
{
    if (input.cancelBurn && TryGetModule<BurnEffectModule>(out var burn))
        burn.Dispose();
}

Constraints on dynamic modules:

  • The module type must expose a public constructor whose first parameter is PredictedIdentity. Any additional parameters must be optional. The reconcile path uses this constructor to recreate the module on remote peers.
  • Per-instance configuration that affects simulation must live in the module's state, not in constructor arguments. Optional constructor parameters fall back to their defaults during reconciliation.
  • Static modules cannot be disposed. Calling Dispose() on a module created before the first simulation tick is a no-op and logs an error.

The onDisposed event fires when a module is torn down, whether by an explicit Dispose() call or by rollback reconciliation. Use it to drop any external references.


Looking up Modules

Caching a dynamic module reference outside state is risky. Rollback may tear the module down and recreate it on replay, leaving the cached reference dangling.

Prefer looking the module up from the identity's live module list:

if (TryGetModule<BurnEffectModule>(out var burn))
    burn.Refresh();

Available accessors on PredictedIdentity:

MemberDescription
modulesThe live, ordered list of modules attached to the identity. Static first, then dynamic in registration order.
TryGetModule<T>(out T module)Returns the first module of type T attached to the identity.
GetModules<T>(List<T> results)Appends every module of type T to the given list, in registration order.

Key Overrides

MethodDescription
SimulateMain Logic. Executed every tick. Modify state here to advance simulation.
UpdateViewVisuals. Executed every frame. Use viewState (interpolated) for smooth rendering.
InitializeSetup. Called when the module is created. Use this instead of Awake/Start.
InterpolateSmoothing. (Optional) Custom logic for blending states between ticks. Defaults to standard linear interpolation.