Skip to content

Porting a Fusion KCC + networked physics game to Lattice

This is the deeper companion to Migrating from Photon Fusion 2.1. That page explains what the fusion2lattice tool rewrites for you and what it leaves on your plate. This page is about the three things that make a real Fusion game a real Fusion game —

  1. a KCC character controller (the KCC addon + its processor stack),
  2. networked physics (NetworkRigidbody / NetworkTransform / RunnerSimulatePhysics), and
  3. networked collections (NetworkArray / NetworkDictionary / NetworkLinkedList).

For the KCC, Lattice now ships a real answer: the CharacterMotor addon with a KCC-compat layer (IKccProcessor / KccData / KccProcessorStep), and the codemod's --kcc adapter mode (the default) auto-converts your KCCProcessor / NetworkKCCProcessor classes into Lattice IKccProcessors for you. Networked physics and animation also ship as addons (ReplicatedBody / ReplicatedTransform, NetworkAnimator, NetworkObjectPool). Networked collections still have no growable Lattice equivalent — Lattice replicates a fixed-size POD block, so those are flagged Manual. This guide gives you the design behind each addon and a concrete Lattice-side pattern for the collection cases, with real com.lattice.netcode code, so you can turn the report's findings into working systems instead of staring at "Left unchanged."

How this maps to your migration report

Each section names the exact "Manual work, by kind" bucket(s) from MIGRATION-SUMMARY.md that it resolves. Run ./run.sh convert first, open the summary, and use these headings as the playbook for working the Manual list top to bottom.

Recommended order — vertical slice first

Do not port everything at once. Build a single-character vertical slice before you touch vehicles, draggables, or collections: one CharacterMotor with gravity + ground move + jump, spawned on a host, predicted on one owning client, with the transform driven from replicated state. Get prediction/reconciliation feeling right on that one object, then fan the same pattern out to every other moving thing. Every later section reuses the motor pattern from §1.


The Lattice mental model (one paragraph)

Lattice replicates a fixed-size POD state block per object. You declare the replicated fields with [Networked], register the type's schema once (same order on every peer — the content hash must match), and the core delta-replicates the dirty fields each snapshot. Your simulation runs in FixedUpdateNetwork(), the fixed-tick callback driven from lattice_runner_tick. On the authority (Host / Server / SharedHost) that callback is the source of truth; on the owning client the core re-runs the same callback during prediction/rollback with buffered input, then reconciles against the authority's snapshot. On proxies (peers that neither own nor have authority) you don't simulate at all — you read the converged, interpolated state in StateUpdated() and apply it to the Transform. Hold that shape in your head; every design below is an instance of it.


1. Character movement — replacing the Fusion KCC

Resolves Manual buckets: GetInput, Iface.INetworkInputProvider, Runner.DeltaTime, and the prediction/tick-timing big rock. (Plus the Review items for FixedUpdateNetwork, the input struct, and the [OnChanged] split.)

What you're replacing

A Fusion KCC project leans on the KCC addon: a KCC component, a KCCSettings/KCCData blob, and a stack of KCCProcessorsEnvironmentProcessor (gravity, ground snapping), your own MovementProcessor, JumpProcessor, CrouchProcessor, LadderProcessor, AutoStepProcessor, coyote-time, and so on. Each tick the KCC runs its processor pipeline against KCCData, and the result is a kinematic capsule move that Fusion predicts and resimulates for you.

The netcode core has no KCC concept — what it gives you is exactly the substrate a KCC is built on: a deterministic fixed tick, a replicated state block, and automatic prediction/rollback. On top of that substrate the com.lattice.netcode package ships a CharacterMotor addon: a small, plain-C# motor you own end to end, with an ordered step pipeline (IMotorStep) that is the direct analogue of the KCC processor stack, plus a KCC-compat layer (IKccProcessor / KccData / KccProcessorStep) whose member names mirror Fusion's KCCData.

The codemod auto-converts your processors — --kcc adapter (default)

You do not have to hand-rewrite every KCCProcessor. fusion2lattice convert runs with --kcc adapter by default: it detects each KCCProcessor / NetworkKCCProcessor, changes the base type to IKccProcessor, rewrites the Process signature, and maps KCCData.X → data.X member access (Position, Velocity, RealVelocity, LookYaw, Height, IsGrounded, Crouching, Tick, LastGroundTick, LastJumpTick) against the shipped KccData. It is a scaffold: collision/sweep calls (kcc.Move / penetration / ICollisionProbe wiring) and the movement feel are flagged for you to finish — unmapped members get an inline comment + a Review finding. Pass --kcc report to detect-only (emit KCC as Manual findings) and port it all by hand instead. The design below explains the target the adapter emits against, so you can read and finish what it produces.

The design: a CharacterMotor over a POD MotorState

Three pieces:

  • MotorState — a blittable struct, the KCC's KCCData analogue. Every field that must replicate is a [Networked] field. This is the whole networked footprint of the character.
  • IMotorStep — the KCC-processor replacement. A plain interface with one method that mutates the state in place. Your Gravity, Move, Jump, Crouch, Ladder, CoyoteTime, AutoClimb processors each become one IMotorStep.
  • CharacterMotor — a NetworkBehaviour that holds the ordered step list and a deterministic Simulate(in MotorInput, float dt) it calls every fixed tick.

MotorState — the replicated POD block

using System.Runtime.InteropServices;
using UnityEngine;

namespace Game.Movement
{
    // Fusion's KCCData -> a flat, blittable POD block. Sequential layout so the schema offsets
    // (Marshal.OffsetOf) match the bytes the core replicates. Keep it small: every field here is
    // wire traffic. Group hot fields; leave room for what your processors actually need.
    [StructLayout(LayoutKind.Sequential)]
    public struct MotorState
    {
        public Vector3 Position;     // capsule foot/origin position
        public Vector3 Velocity;     // m/s, integrated by the steps
        public float   Yaw;          // facing; pitch usually stays client-only/cosmetic
        public byte    Flags;        // bitfield: grounded / crouching / onLadder / jumpHeld
        public ushort  CoyoteTicks;  // ticks since leaving ground (coyote-time window)
        public byte    JumpsLeft;    // multi-jump budget
    }

    [System.Flags]
    public enum MotorFlag : byte
    {
        None      = 0,
        Grounded  = 1 << 0,
        Crouching = 1 << 1,
        OnLadder  = 1 << 2,
        JumpHeld  = 1 << 3,
    }
}

MotorInput — the per-tick input (the INetworkInput replacement)

The migration tool strips : INetworkInput from your input struct (Lattice has no such marker) and flags input routing as Manual. The struct itself stays a plain POD — exactly what you want here:

namespace Game.Movement
{
    // Was: struct PlayerInput : INetworkInput. Now a plain POD the host collects each tick and
    // the motor consumes. Keep it blittable so it round-trips through the input-poll cleanly.
    public struct MotorInput
    {
        public Vector2 Move;   // -1..1 stick / WASD, in local space
        public float   Yaw;    // look yaw (deg)
        public bool    Jump;   // edge-triggered by the step that reads it
        public bool    Crouch;
        public bool    Up;     // ladder climb axis
    }
}

IMotorStep — one interface, many processors

namespace Game.Movement
{
    // The KCCProcessor replacement. A step reads input, mutates state in place, runs in a FIXED
    // ORDER. No Fusion base class, no reflection, no addon -- just an ordered list you control.
    public interface IMotorStep
    {
        void Run(ref MotorState s, in MotorInput i, float dt);
    }
}

Now each Fusion processor becomes one tiny, testable step. A few representative ones:

using UnityEngine;

namespace Game.Movement
{
    // EnvironmentProcessor (gravity half). Order matters: gravity BEFORE the ground check so the
    // ground step can cancel downward velocity in the same tick.
    public sealed class GravityStep : IMotorStep
    {
        readonly float _g;
        public GravityStep(float gravity = 24f) { _g = gravity; }
        public void Run(ref MotorState s, in MotorInput i, float dt)
        {
            if ((s.Flags & (byte)MotorFlag.OnLadder) != 0) return;
            s.Velocity.y -= _g * dt;
        }
    }

    // Your MovementProcessor: planar acceleration toward the desired direction. Note dt is the
    // FIXED step passed in -- never Runner.DeltaTime, never Time.deltaTime. Determinism depends on it.
    public sealed class MoveStep : IMotorStep
    {
        readonly float _speed, _accel;
        public MoveStep(float speed = 6f, float accel = 50f) { _speed = speed; _accel = accel; }
        public void Run(ref MotorState s, in MotorInput i, float dt)
        {
            s.Yaw = i.Yaw;
            Quaternion rot = Quaternion.Euler(0f, i.Yaw, 0f);
            Vector3 wish = rot * new Vector3(i.Move.x, 0f, i.Move.y);
            if (wish.sqrMagnitude > 1f) wish.Normalize();

            bool crouch = (s.Flags & (byte)MotorFlag.Crouching) != 0;
            Vector3 target = wish * (_speed * (crouch ? 0.5f : 1f));
            Vector3 planar = new Vector3(s.Velocity.x, 0f, s.Velocity.z);
            planar = Vector3.MoveTowards(planar, target, _accel * dt);
            s.Velocity.x = planar.x; s.Velocity.z = planar.z;
        }
    }

    // JumpProcessor + coyote-time. Edge-triggered: only fires on the tick Jump goes from up->down,
    // and only inside the coyote window after leaving ground. CoyoteTicks is replicated so the
    // predicting client and the authority agree on the window.
    public sealed class JumpStep : IMotorStep
    {
        readonly float _vel; readonly ushort _coyote; readonly byte _maxJumps;
        public JumpStep(float jumpVel = 8f, ushort coyoteTicks = 6, byte maxJumps = 1)
        { _vel = jumpVel; _coyote = coyoteTicks; _maxJumps = maxJumps; }

        public void Run(ref MotorState s, in MotorInput i, float dt)
        {
            bool grounded = (s.Flags & (byte)MotorFlag.Grounded) != 0;
            bool wasHeld  = (s.Flags & (byte)MotorFlag.JumpHeld) != 0;
            bool edge     = i.Jump && !wasHeld;
            bool canCoyote = s.CoyoteTicks <= _coyote;

            if (edge && (grounded || canCoyote || s.JumpsLeft > 0))
            {
                s.Velocity.y = _vel;
                s.CoyoteTicks = ushort.MaxValue;   // consume the window
                if (!grounded && s.JumpsLeft > 0) s.JumpsLeft--;
            }
            if (i.Jump) s.Flags |= (byte)MotorFlag.JumpHeld;
            else        s.Flags &= unchecked((byte)~(byte)MotorFlag.JumpHeld);
        }
    }
}

A CrouchStep, LadderStep (zeroes gravity, drives vertical from MotorInput.Up while OnLadder), and an AutoClimbStep (raises Position.y over a small step height when blocked by a low obstacle) follow the same shape. The point is: a Fusion processor stack becomes an ordered list of pure functions over a mutable struct. No addon, no inheritance, fully unit-testable off the network.

CharacterMotor — the NetworkBehaviour that drives it

using System.Collections.Generic;
using UnityEngine;
using Lattice;

namespace Game.Movement
{
    public sealed class CharacterMotor : Lattice.NetworkBehaviour
    {
        // The replicated block. One [Networked] field per MotorState member would be the idiomatic
        // spelling; replicating the whole struct (one [Networked] MotorState) is fine too and keeps
        // the predicted block contiguous. Interpolate on proxies, predict on the owner.
        [Networked, Interpolation] public Vector3 Position { get; set; }
        [Networked]                public Vector3 Velocity { get; set; }
        [Networked]                public float   Yaw      { get; set; }
        [Networked]                public byte    Flags    { get; set; }
        [Networked]                public ushort  CoyoteTicks { get; set; }
        [Networked]                public byte    JumpsLeft   { get; set; }

        [SerializeField] CharacterController _cc;   // the capsule (collision query only)
        [SerializeField] float _groundProbe = 0.15f;

        // The processor stack -- order IS the design, exactly like the KCC pipeline order.
        readonly List<IMotorStep> _steps = new()
        {
            new GravityStep(24f),
            new MoveStep(speed: 6f, accel: 50f),
            new JumpStep(jumpVel: 8f, coyoteTicks: 6, maxJumps: 1),
            // new CrouchStep(), new LadderStep(), new AutoClimbStep(), ...
        };

        float FixedDt => 1f / 60f;   // the fixed step; see "Runner.DeltaTime" below

        // THE tick. On the authority it's truth; on the owning client the core re-runs THIS during
        // rollback with buffered input, then reconciles. Same code, both paths -- so it MUST be
        // deterministic: only FixedDt, no Time.deltaTime, no Random, no per-frame globals.
        public override void FixedUpdateNetwork()
        {
            // Only the authority and the input owner simulate. Proxies converge in StateUpdated().
            if (!HasStateAuthority && !HasInputAuthority) return;

            if (!TryGetInput(out MotorInput input)) input = default;

            MotorState s = ReadState();
            GroundCheck(ref s);                 // collision query -> sets Grounded, resets coyote
            foreach (var step in _steps)        // the processor pipeline
                step.Run(ref s, in input, FixedDt);
            Integrate(ref s, FixedDt);          // apply velocity through the CharacterController
            WriteState(s);
        }

        void Integrate(ref MotorState s, float dt)
        {
            // CharacterController.Move resolves capsule collision deterministically enough for a
            // predicted controller (it's a swept query, not rigidbody dynamics). Drive position
            // from it, then read the resolved position back into state.
            _cc.transform.position = s.Position;
            _cc.Move(s.Velocity * dt);
            s.Position = _cc.transform.position;
            if (_cc.isGrounded) s.Flags |= (byte)MotorFlag.Grounded;
        }

        void GroundCheck(ref MotorState s)
        {
            bool grounded = Physics.Raycast(s.Position + Vector3.up * 0.05f, Vector3.down,
                                            _groundProbe + 0.05f);
            if (grounded) { s.Flags |= (byte)MotorFlag.Grounded; s.CoyoteTicks = 0; s.JumpsLeft = 1; }
            else          { s.Flags &= unchecked((byte)~(byte)MotorFlag.Grounded);
                            if (s.CoyoteTicks < ushort.MaxValue) s.CoyoteTicks++; }
        }

        // Proxies (not owner, not authority): never simulate. Apply the converged, interpolated
        // [Networked] Position/Yaw to the Transform. This is the Lattice render-apply hook.
        public override void StateUpdated()
        {
            if (HasStateAuthority || HasInputAuthority) return;
            transform.SetPositionAndRotation(Position, Quaternion.Euler(0f, Yaw, 0f));
        }

        // --- glue between the struct block and the [Networked] properties ---
        MotorState ReadState() => new()
        {
            Position = Position, Velocity = Velocity, Yaw = Yaw,
            Flags = Flags, CoyoteTicks = CoyoteTicks, JumpsLeft = JumpsLeft
        };
        void WriteState(in MotorState s)
        {
            Position = s.Position; Velocity = s.Velocity; Yaw = s.Yaw;
            Flags = s.Flags; CoyoteTicks = s.CoyoteTicks; JumpsLeft = s.JumpsLeft;
            transform.SetPositionAndRotation(s.Position, Quaternion.Euler(0f, s.Yaw, 0f)); // owner render
        }

        // Stand-in for the typed GetInput Fusion gives you. See "Input flow" below: this binding
        // has no typed GetInput<T> on NetworkBehaviour, so input arrives via the host poll and you
        // stash it where the motor can read it for the current tick.
        bool TryGetInput(out MotorInput input) => InputRouter.TryGet(Object.NetId, out input);
    }
}

Input flow — there is no GetInput<T> in this binding

This is the single biggest behavioural change versus Fusion, and it's why the report marks both GetInput and INetworkInputProvider Manual. In Fusion you implement INetworkInputProvider.OnInput, return a struct, and read it back with GetInput(out MyInput). The shipped com.lattice.netcode binding exposes neither. Input collection is a host concern.

The pattern:

  1. The owning client samples local devices each tick into a MotorInput.
  2. It ships that input to the authority. With this binding you carry it as the RPC / custom-event payload — an object-scoped RPC (LatticeRpcTarget.Server) or a custom event targeting Server, with the MotorInput serialized into the byte[].
  3. The authority's tick deserializes it in OnRpc / OnEvent, stashes it keyed by netid, and the motor's TryGetInput reads it for the current tick.

A minimal router both sides share:

using System.Collections.Generic;
using System.Runtime.InteropServices;

namespace Game.Movement
{
    // The INetworkInputProvider replacement: a per-netid input mailbox the host fills (from OnRpc/
    // OnEvent) and the motor drains. On the owning client it's filled locally each tick before Tick().
    public static class InputRouter
    {
        static readonly Dictionary<ulong, MotorInput> _latest = new();

        public static void Set(ulong netid, in MotorInput i) => _latest[netid] = i;
        public static bool TryGet(ulong netid, out MotorInput i) => _latest.TryGetValue(netid, out i);

        // Blittable POD <-> bytes for the RPC/event payload. (No reflection on the hot path.)
        public static byte[] Serialize(in MotorInput i)
        {
            int n = Marshal.SizeOf<MotorInput>(); var buf = new byte[n];
            var h = GCHandle.Alloc(buf, GCHandleType.Pinned);
            try { Marshal.StructureToPtr(i, h.AddrOfPinnedObject(), false); } finally { h.Free(); }
            return buf;
        }
        public static MotorInput Deserialize(byte[] buf)
        {
            var h = GCHandle.Alloc(buf, GCHandleType.Pinned);
            try { return Marshal.PtrToStructure<MotorInput>(h.AddrOfPinnedObject()); } finally { h.Free(); }
        }
    }
}

Wiring it to the runner (host side), using the OnEvent hook — note the sender arg is your RpcInfo replacement (see §4):

const ushort EV_INPUT = 1;

// Owner each tick, before runner.Tick(dt):
var mine = SampleLocalDevices();
InputRouter.Set(motor.Object.NetId, mine);                 // local prediction reads this
motor.Object.SendEvent(EV_INPUT, LatticeEventTarget.Server,
                       InputRouter.Serialize(mine));        // ship to authority

// Authority, once at startup:
runner.OnEvent += (eventId, netid, sender, payload) =>
{
    if (eventId == EV_INPUT)
        InputRouter.Set(netid, InputRouter.Deserialize(payload));   // 'sender' = who sent it
};

Prediction, rollback, and determinism

  • Why it self-corrects. You never write a reconciliation loop. The core predicts on the owner and reconciles against the authority's snapshot. Your only obligation is that FixedUpdateNetwork is a pure function of (MotorState, MotorInput, fixedDt) — because the core re-runs it during rollback. Any hidden dependency on Time.deltaTime, frame count, Random, or mutable statics breaks reconciliation and you'll see rubber-banding.
  • Fixed step only. Pass 1f/tickRate (here 1f/60f) to every step. This is the literal Runner.DeltaTime replacement (see §4); it's the same number on every peer and every resim pass.
  • Integer-friendly math where it bites. Cross-platform float drift is the enemy of rollback. Counters that gate behaviour — coyote window, jump budget, buffered-jump grace — are integer ticks (CoyoteTicks, JumpsLeft) precisely so they reconcile exactly. Keep float work to the continuous quantities (position, velocity) and quantize replicated floats with [Networked]'s QuantMin/QuantMax/QuantPrecision hints to bound divergence on the wire.
  • Interpolation is for proxies only. Mark Position [Networked, Interpolation] so remote proxies render smoothly from the ~100 ms snapshot buffer; the owner ignores interpolation and uses its predicted value directly.

2. Networked physics — replacing NetworkRigidbody / NetworkTransform / RunnerSimulatePhysics

Resolves Manual buckets: the Networked transform mapping row (NetworkTransform / NetworkRigidbody / NetworkRigidbody2D), Runner.DeltaTime, and any RunnerSimulatePhysics / custom physics-runner code.

The honest framing first

Fusion can run a lockstep, fully-networked physics world (RunnerSimulatePhysics, the physics addon, NetworkRigidbody) where every peer simulates the same Rigidbodys deterministically. Lattice does not ship that, and you should think hard before trying to rebuild it: cross-platform deterministic rigidbody dynamics (PhysX/Box2D producing byte-identical results on Windows, Linux, macOS, mobile) is genuinely hard and fragile. There is no byte-identical NetworkRigidbody clone.

What Lattice ships instead: the ReplicatedBody addon (authority-simulates + replicate). The com.lattice.netcode package ships ReplicatedTransform and ReplicatedBody so you don't hand-roll this: one peer (the authority) runs ordinary Unity physics in the fixed tick and replicates the result — position and rotation — as [Networked] fields; everyone else is a kinematic proxy that just renders the converged transform. You give up client-side rigidbody prediction; you gain a system that actually works across platforms and is simple to reason about. This is the same shape as the character motor in §1: authority simulates, state replicates, proxies interpolate. The component below is the design of that addon — read it to understand and extend what ships.

flowchart LR
    A["Authority<br/>Rigidbody = dynamic<br/>Unity physics in FixedTick"] -->|"[Networked] pos/rot"| W["snapshot"]
    W --> P1["Proxy<br/>Rigidbody = kinematic<br/>render interpolated"]
    W --> P2["Proxy<br/>Rigidbody = kinematic<br/>render interpolated"]

The replicated body component

using UnityEngine;
using Lattice;

namespace Game.Physics
{
    // Replaces NetworkRigidbody/NetworkTransform. Authority: dynamic Rigidbody, Unity simulates,
    // we copy the result into [Networked] fields. Proxy: kinematic, render from interpolated state.
    [RequireComponent(typeof(Rigidbody))]
    public sealed class ReplicatedBody : Lattice.NetworkBehaviour
    {
        [Networked, Interpolation] public Vector3    Position { get; set; }
        [Networked, Interpolation] public Quaternion Rotation { get; set; }
        // Replicate velocity too if proxies need it for VFX/audio or dead-reckoning.
        [Networked]                public Vector3    Velocity { get; set; }

        Rigidbody _rb;

        public override void Spawned()
        {
            _rb = GetComponent<Rigidbody>();
            // The single most important line: only the authority's body is dynamic.
            _rb.isKinematic = !HasStateAuthority;
            _rb.interpolation = HasStateAuthority
                ? RigidbodyInterpolation.None        // authority steps in the fixed tick
                : RigidbodyInterpolation.Interpolate; // proxy smooths between snapshots
        }

        public override void FixedUpdateNetwork()
        {
            if (!HasStateAuthority) return;
            // Unity already integrated the Rigidbody this FixedUpdate; publish the result.
            Position = _rb.position;
            Rotation = _rb.rotation;
            Velocity = _rb.linearVelocity;
        }

        public override void StateUpdated()
        {
            if (HasStateAuthority) return;
            // Proxy: drive the kinematic body to the converged transform. MovePosition/MoveRotation
            // (not transform=) so the kinematic body still pushes/triggers correctly.
            _rb.MovePosition(Position);
            _rb.MoveRotation(Rotation);
        }
    }
}

FixedUpdate vs FixedUpdateNetwork ordering

Unity's FixedUpdate integrates the Rigidbody; Lattice's FixedUpdateNetwork runs from runner.Tick(dt). Run them at the same rate and call runner.Tick(1f/60f) from Unity's FixedUpdate (the binding's editor NetworkRunner.FixedUpdate already does this) so "publish the result" reads a freshly-integrated body. If you decouple the rates you'll publish stale transforms.

Vehicles (CarController / MotorbikeController)

A car is just a ReplicatedBody whose input is steering/throttle/brake instead of WASD, and whose simulation is WheelCollider physics instead of a capsule move. Reuse everything:

  • The car's Rigidbody + WheelColliders are dynamic on the authority, kinematic on proxies — identical to ReplicatedBody.Spawned.
  • Driver input (MotorInput-style: Throttle, Steer, Brake, Handbrake) flows through the same InputRouter from §1 — an event to the authority each tick.
  • The authority applies WheelCollider.motorTorque/steerAngle/brakeTorque in FixedUpdateNetwork, Unity integrates, and Position/Rotation replicate. Proxies render the interpolated chassis; spin wheel meshes locally from replicated Velocity (cosmetic, never networked per-wheel).
public override void FixedUpdateNetwork()
{
    if (!HasStateAuthority) return;
    if (!InputRouter.TryGet(Object.NetId, out var inp)) inp = default;

    foreach (var w in _driveWheels) w.motorTorque = inp.Throttle * _enginePower;
    foreach (var w in _steerWheels) w.steerAngle  = inp.Steer * _maxSteer;
    foreach (var w in _allWheels)   w.brakeTorque = inp.Brake * _brakeForce;

    Position = _rb.position; Rotation = _rb.rotation; Velocity = _rb.linearVelocity;
}

A MotorbikeController differs only in its lean/balance logic — still authority-side Unity physics, still replicated transform. The networking is the same; the vehicle dynamics stay your local code.

Draggables (grab / throw objects)

Same ReplicatedBody, plus an authority transfer when a player grabs one. A draggable is usually a shared object so whoever grabs it gets to simulate it:

  1. On grab, the grabbing client calls Object.RequestAuthority() on the draggable.
  2. The arbiter grants it; every peer sees OnAuthorityChanged(netid, newOwner, tick).
  3. In an OnAuthorityChanged handler, each peer flips its local body: _rb.isKinematic = (CurrentOwner != Runner.LocalPlayer). The new owner's body goes dynamic and starts simulating + replicating; everyone else goes kinematic and renders.
  4. On release, the holder either keeps authority (it settles where dropped) or hands it back to the host.

This makes "who simulates this crate right now" a first-class, replicated decision rather than a guess. It's the distributed-authority story the migration report points at for SharedHost.

Rebuilding a custom physics runner (RunnerSimulatePhysics / RunnerCustomPhysics)

If your Fusion game disabled auto-physics and drove Physics.Simulate() itself in a custom runner, rebuild that loop on Lattice's tick instead of Unity's:

// Custom physics runner on the Lattice fixed tick. Authority only -- proxies never call Simulate().
public sealed class CustomPhysicsRunner : Lattice.NetworkBehaviour
{
    [SerializeField] PhysicsScene _scene; // a dedicated PhysicsScene if you isolate sim from render

    public override void FixedUpdateNetwork()
    {
        if (!HasStateAuthority) return;
        float dt = 1f / 60f;          // the fixed step -- the Runner.DeltaTime replacement
        _scene.Simulate(dt);          // your old RunnerSimulatePhysics body, now tick-driven
        // then publish each tracked body's transform into its [Networked] fields (as above)
    }
}

The key change: the cadence comes from runner.Tick(dt), not Unity's auto-simulation, and you must turn off Unity auto-simulation (Physics.simulationMode = SimulationMode.Script) so you own the step. Everything downstream (publish transforms, proxies kinematic) is unchanged.

If you truly need lockstep deterministic physics

It's possible but it's a project, not a port: a fixed-point or carefully-constrained float physics layer, identical solver iteration counts and contact ordering on every platform, and the shared game-sim module (design 07) so client and server run byte-identical code. For the overwhelming majority of games, authority-simulated + replicated is the right call — ship that first.


3. Networked collections

Resolves Manual buckets: Collection.NetworkArray, Collection.NetworkDictionary, Collection.NetworkLinkedList.

Fusion gives you NetworkArray<T>, NetworkLinkedList<T>, and NetworkDictionary<K,V> — replicated, growable-ish containers. Lattice has none. It replicates a fixed-size POD block, so there is no growable networked collection at all. The report says exactly this and tells you the three escape hatches. Here they are, concretely.

Pattern A — fixed-capacity array field (the default)

If the collection has a known, bounded maximum, inline it as a fixed-size buffer in your state struct plus a Count. This is the direct NetworkArray<T> replacement.

using System.Runtime.InteropServices;

namespace Game.Collections
{
    [StructLayout(LayoutKind.Sequential)]
    public struct Inventory
    {
        public const int Cap = 8;          // BOUNDED capacity -- pick the real ceiling

        public int Count;                  // logical length (0..Cap)
        // C# can't put a managed array inline; use a fixed buffer or explicit slots.
        public InventorySlot Slot0, Slot1, Slot2, Slot3, Slot4, Slot5, Slot6, Slot7;

        public InventorySlot Get(int i) => i switch {
            0 => Slot0, 1 => Slot1, 2 => Slot2, 3 => Slot3,
            4 => Slot4, 5 => Slot5, 6 => Slot6, _ => Slot7 };
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct InventorySlot { public int ItemId; public ushort Qty; }
}

Register it declaratively and replicate it as the object's [Networked] block. Mutate on the authority (push = write Slot{Count}, Count++, mark dirty); read everywhere. Bounded by construction, blittable, zero allocation.

Prefer a fixed buffer when blittable

For primitive element types you can use an unsafe fixed buffer (public fixed int Items[8];) inside the struct, which is cleaner than enumerated slots. Keep the struct blittable so it round-trips through Marshal.StructureToPtr and the schema offsets line up.

Pattern B — a Bytes field you (de)serialize yourself

When the layout is irregular or you want tight packing, replicate a single Bytes field (the LATTICE_FIELD_BYTES kind, with a fixed byte_capacity) and own the wire format. This is the NetworkDictionary / NetworkLinkedList replacement — you serialize whatever structure you like into the buffer using the BitWriter/BitReader helpers, within a bounded capacity.

// Conceptual: pack a small dictionary into a bounded Bytes field.
// Authority writes:
var w = new BitWriter();
w.WriteByte((byte)entries.Count);          // bounded: assert Count <= MaxEntries
foreach (var (key, val) in entries) { w.WriteInt(key); w.WriteInt(val); }
WriteBytesField(stateBlock, w.ToArray());  // capacity checked against byte_capacity

// Everyone reads:
var r = new BitReader(ReadBytesField(stateBlock));
int n = r.ReadByte();
for (int i = 0; i < n; i++) dict[r.ReadInt()] = r.ReadInt();

The capacity is fixed at schema-registration time (FieldSpec.WithCapacity(bytes)), so you must pick a real ceiling — Lattice will not grow it for you.

Pattern C — RPC / event-driven deltas

For a log-like or append-heavy collection (kill feed, chat history, pickup events) where you don't need the whole thing in the replicated block, don't replicate the container at all — replicate the changes as custom events and let each peer maintain its own local copy:

const ushort EV_ITEM_ADDED = 10;

// Authority appends + broadcasts the delta:
void AddItem(int itemId)
{
    _serverList.Add(itemId);                 // authoritative local copy
    var w = new BitWriter(); w.WriteInt(itemId);
    runner.SendEvent(EV_ITEM_ADDED, LatticeEventTarget.All, w.ToArray());
}

// Every peer applies the delta to its own list:
runner.OnEvent += (id, netid, sender, payload) =>
{
    if (id == EV_ITEM_ADDED)
        _localList.Add(new BitReader(payload).ReadInt());
};

This trades replicated convergence guarantees for flexibility, so use reliable events for deltas that must not be lost, and have late-joiners fetch a snapshot (a Bytes field or an authority RPC on spawn) so they don't miss history.

Choosing between them

Your Fusion type Use When
NetworkArray<T> (small, bounded) A — fixed-capacity array field Known max, fits the POD block
NetworkDictionary<K,V> BBytes field you pack Irregular/keyed, still bounded
NetworkLinkedList<T> (append log) C — event deltas + local copy Append-heavy, history not needed in-block
Anything unbounded rethink the design Lattice replicates fixed-size blocks — there is no unbounded option

The non-negotiable rule the report repeats for all three: capacity must be bounded.


4. Small but real items

Resolves Manual buckets: Runner.DeltaTime, RpcInfo, Using.FusionSub, AreaOfInterest.

Runner.DeltaTime → your fixed step

NetworkRunner in this binding has no DeltaTime. Everywhere Fusion code read Runner.DeltaTime, substitute the fixed step you tick with:

float FixedDt => 1f / tickRateHz;   // e.g. 1f/60f; or Time.fixedDeltaTime in the editor

It's a constant, it's identical on every peer, and it's identical on every rollback resim pass — which is exactly why prediction works. Never reach for Time.deltaTime inside FixedUpdateNetwork.

RpcInfo → the OnEvent/OnRpc sender

Fusion's RpcInfo info parameter (with info.Source) has no Lattice type. Sender identity arrives as an argument, not a magic parameter:

  • For a custom event, OnEvent(eventId, netid, **sender**, payload) gives you the originating peer (0 == local authority).
  • For an RPC, OnRpc(netid, rpcId, payload) doesn't carry a sender, so if you need it, carry it in the payload (serialize the caller's id) or use a custom event instead, which does.
runner.OnEvent += (eventId, netid, sender, payload) =>
{
    // 'sender' is your RpcInfo.Source replacement.
    if (eventId == EV_FIRE && IsAllowedToFire(sender)) ApplyFire(netid, payload);
};

using Fusion.Sockets; and other Fusion sub-namespaces

The codemod removes Fusion sub-namespaces (no Lattice equivalent) and leaves a note. Just delete the dead using and add the Lattice usings you actually need (using Lattice;, using Lattice.Interop; for the enums). Nothing maps; nothing is lost.

AreaOfInterest

Lattice runs interest management in the core; there is no per-object AOI API in this binding. Remove AreaOfInterest / SetPlayerAlwaysInterested calls and rely on core defaults. If you genuinely need custom relevance, that's a core/director concern, not gameplay code — out of scope for the port.


Putting it together — the porting checklist

Work your MIGRATION-SUMMARY.md "Manual work, by kind" list against this guide:

  1. Vertical slice (§1). Build one CharacterMotor + a couple of IMotorSteps (gravity, move, jump). Wire InputRouter (resolves GetInput, INetworkInputProvider). Spawn it on a host, predict it on one owning client, apply state to the Transform in StateUpdated. Confirm prediction feels right before going further.
  2. Port the rest of the KCC processor stack (§1) into IMotorSteps — crouch, ladder, coyote, auto-climb — one at a time, unit-testing each off-network.
  3. Networked physics (§2). Convert NetworkRigidbody/NetworkTransform objects to ReplicatedBody (authority dynamic, proxies kinematic). Do vehicles and draggables with the same component; rebuild any RunnerSimulatePhysics loop on the Lattice tick.
  4. Collections (§3). Replace each NetworkArray/NetworkDictionary/NetworkLinkedList with pattern A, B, or C — always bounded.
  5. Small items (§4). Sweep Runner.DeltaTime, RpcInfo, dead Fusion usings, and AreaOfInterest.
  6. Compile against com.lattice.netcode, fix what the compiler finds, then smoke-test the Auto-converted paths and validate behaviour on everything above.

See also