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 —
- a KCC character controller (the
KCCaddon + its processor stack), - networked physics (
NetworkRigidbody/NetworkTransform/RunnerSimulatePhysics), and - 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 forFixedUpdateNetwork, 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 KCCProcessors — EnvironmentProcessor (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'sKCCDataanalogue. 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. YourGravity,Move,Jump,Crouch,Ladder,CoyoteTime,AutoClimbprocessors each become oneIMotorStep.CharacterMotor— aNetworkBehaviourthat holds the ordered step list and a deterministicSimulate(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:
- The owning client samples local devices each tick into a
MotorInput. - 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 targetingServer, with theMotorInputserialized into thebyte[]. - The authority's tick deserializes it in
OnRpc/OnEvent, stashes it keyed bynetid, and the motor'sTryGetInputreads 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
FixedUpdateNetworkis a pure function of(MotorState, MotorInput, fixedDt)— because the core re-runs it during rollback. Any hidden dependency onTime.deltaTime, frame count,Random, or mutable statics breaks reconciliation and you'll see rubber-banding. - Fixed step only. Pass
1f/tickRate(here1f/60f) to every step. This is the literalRunner.DeltaTimereplacement (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]'sQuantMin/QuantMax/QuantPrecisionhints to bound divergence on the wire. Interpolationis for proxies only. MarkPosition[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 transformmapping row (NetworkTransform/NetworkRigidbody/NetworkRigidbody2D),Runner.DeltaTime, and anyRunnerSimulatePhysics/ 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 toReplicatedBody.Spawned. - Driver input (
MotorInput-style:Throttle,Steer,Brake,Handbrake) flows through the sameInputRouterfrom §1 — an event to the authority each tick. - The authority applies
WheelCollider.motorTorque/steerAngle/brakeTorqueinFixedUpdateNetwork, Unity integrates, andPosition/Rotationreplicate. Proxies render the interpolated chassis; spin wheel meshes locally from replicatedVelocity(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:
- On grab, the grabbing client calls
Object.RequestAuthority()on the draggable. - The arbiter grants it; every peer sees
OnAuthorityChanged(netid, newOwner, tick). - In an
OnAuthorityChangedhandler, 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. - 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> |
B — Bytes 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:
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:
- Vertical slice (§1). Build one
CharacterMotor+ a couple ofIMotorSteps (gravity, move, jump). WireInputRouter(resolvesGetInput,INetworkInputProvider). Spawn it on a host, predict it on one owning client, apply state to the Transform inStateUpdated. Confirm prediction feels right before going further. - 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. - Networked physics (§2). Convert
NetworkRigidbody/NetworkTransformobjects toReplicatedBody(authority dynamic, proxies kinematic). Do vehicles and draggables with the same component; rebuild anyRunnerSimulatePhysicsloop on the Lattice tick. - Collections (§3). Replace each
NetworkArray/NetworkDictionary/NetworkLinkedListwith pattern A, B, or C — always bounded. - Small items (§4). Sweep
Runner.DeltaTime,RpcInfo, dead Fusionusings, andAreaOfInterest. - 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¶
- Migrating from Photon Fusion 2.1 — what the tool rewrites, the full concept-mapping table, and the four big rocks.
- lattice-gamedev Claude Code skill — teaches an assistant the real spawn / RPC / input / authority idioms used throughout this guide.
- API reference: Types & objects · RPC · Custom events · Authority & ownership · Runner lifecycle · BitWriter / BitReader
- Design 05 — Engine Integration (§4.3 is the canonical predicted-movement sample this guide's motor mirrors).
- Design 07 — Custom Module Guide — for the lockstep /
shared-
game-simpath if you ever need byte-identical deterministic physics.