Skip to content

Migrating from Photon Fusion 2.1 to Lattice

fusion2lattice is an assisted migration tool that ports a Unity game from Photon Fusion 2.1 to Lattice. It rewrites the high-confidence, mechanical parts of the Fusion API surface to their Lattice equivalents, and produces a per-file report telling you exactly what still needs human work and how to do it.

This is assisted migration, not a one-click port

The tool does not produce a finished, compiling port. It produces a structured starting point plus a precise to-do list. The hard parts of a netcode port — prediction / tick timing, the prefab+transform → type+POD-state spawn model, RPC argument marshalling, and input routing — do not have clean 1:1 mappings. The tool reports them; it does not solve them. Plan on reviewing and hand-finishing the output before it compiles or runs.

What the tool actually does

  1. Analyzer + codemod (convert) — scans a Unity C# project directory, rewrites the high-confidence Fusion → Lattice mappings, and writes the converted .cs into a separate output tree. Your input is never modified. Every change and every un-mappable construct is recorded as a finding.
  2. Migration report — a *.report.md / *.report.json per file plus a top-level MIGRATION-SUMMARY.*. Each finding carries the source line, the Fusion construct, the Lattice result, and concrete guidance, bucketed by confidence (Auto / Review / Manual).
  3. Guide (guide) — emits FUSION-MIGRATION-GUIDE.md: the full concept-mapping table and per-feature notes. It is generated from the same mapping table the codemod uses, so the guide can never drift from the tool's behaviour.

The codemod uses Roslyn (Microsoft.CodeAnalysis.CSharp), not regex: it parses each file to a syntax tree and rewrites it with a CSharpSyntaxRewriter, so it matches real attributes, base lists, member access and invocations, and preserves your formatting and trivia.

The rewrite is syntactic, not semantic

There is no Photon Fusion SDK in this environment, so the tool reasons about names, attributes and call shapes — exactly what you read off the page — not resolved Fusion symbols. A confusingly-named local symbol can produce a false positive. That is what the report is for: review every finding against your actual code.

Honest limitations — read before trusting the output

  • Output does not compile as-is. It needs the Lattice Unity package (com.lattice.netcode) referenced and every Review/Manual item resolved. Each generated file starts with a banner that says exactly this.
  • No real-Fusion-SDK validation. The tool was developed against the Lattice target API (lattice.h, the Unity binding, and design 05 — Engine Integration) plus a synthetic Fusion sample. It has not been validated against the real Photon Fusion SDK, and the converted output has not been compiled against either SDK.
  • The shipped binding is thinner than the docs' ideal in places. Design 05 shows a richer intended idiom (typed GetInput<T>, a two-arg [Rpc(source, target)]). The shipped com.lattice.netcode binding exposes a single LatticeRpcTarget and no typed GetInput. The tool deliberately targets the real binding and flags the gap, rather than emitting calls that don't exist yet.

Prerequisites

  • .NET 8 SDK (the tool is a net8.0 console app).
  • The tool itself — under tools/fusion2lattice/ in the Lattice repo.
  • For the output: the Lattice Unity package (com.lattice.netcode) to reference once you start fixing up the converted files.

Build & run

The tool ships with filesystem-safe wrapper scripts. The repo lives on a mounted filesystem that rejects the chmod/utime calls dotnet makes in obj//bin/, so the build redirects artifacts to /tmp/f2l-build and runs the tool as a DLL via dotnet (the .csproj sets UseAppHost=false). The wrappers handle all of this:

cd tools/fusion2lattice

./build.sh                                 # builds to $BUILD_DIR (default /tmp/f2l-build)
./run.sh convert <UnityProjectDir> <outDir> [--kcc <adapter|report>]  # scan + rewrite + report
./run.sh guide   FUSION-MIGRATION-GUIDE.md    # (re)generate the concept guide

convert takes an optional --kcc <adapter|report> flag controlling how a detected Fusion KCC processor is handled:

  • adapter (the default) — detect + auto-convert each KCCProcessor / NetworkKCCProcessor into a Lattice IKccProcessor (base type, Process signature, and KCCData.X → data.X member mapping). Collision/sweep calls and movement feel are flagged for you to finish; see Porting a Fusion KCC + physics game §1.
  • report — detect only; emit the KCC constructs as Manual findings so you port them by hand.

(convert also takes --exclude <glob> and --no-default-excludes; see ./run.sh with no args for the full usage.)

On a normal filesystem you can skip the wrappers and use dotnet run -- convert <in> <out> directly.

convert always exits 0 even when there are Manual findings — Manual findings are expected output, not a failure. A non-zero exit means a usage or I/O error (e.g. the input directory does not exist, or the output path equals the input path — the tool refuses to clobber your sources).

Walkthrough: the bundled sample project

The repo ships a synthetic Fusion-2.1-style Unity project under sample-fusion-project/ (several NetworkBehaviours, a KCCProcessor, an INetworkInput struct, and a launcher) that deliberately exercises the tricky surface: [Networked]/OnChanged, [Rpc] with sources+targets, FixedUpdateNetwork, GetInput, authority checks, Runner.Spawn, StartGame(StartGameArgs), INetworkRunnerCallbacks, INetworkInputProvider, networked collections, AreaOfInterest, and a KCC KCCProcessor (so you can see the --kcc adapter conversion).

Run the tool on it:

cd tools/fusion2lattice
./build.sh
./run.sh convert sample-fusion-project converted-output

You will see:

Scanned sample-fusion-project
  files with Fusion constructs: 6
  auto-converted: 35, review: 20, manual: 11
  KCC processors converted: 1 (--kcc adapter)
  skipped: 0 file(s) (globs: **/Photon/**)
Output + reports written to .../converted-output
  see converted-output/MIGRATION-SUMMARY.md

That is 66 detected constructs across 6 files: 35 auto-converted, 20 converted-needs-review, 11 manual — plus 1 KCC processor auto-converted by the default --kcc adapter mode (its base type, Process signature, and KCCData member access are rewritten; the collision wiring is left as Review TODOs). The converted tree and its reports are committed under converted-output/ as proof the tool runs end-to-end.

Step 1 — read the generated banner

Every converted file opens with a banner. It is not decoration — it is the contract:

// =====================================================================
// GENERATED by fusion2lattice -- assisted Fusion 2.1 -> Lattice migration.
// Source: Scripts/PlayerController.cs
// THIS IS NOT A FINISHED PORT. It will not compile until you:
//   1. reference the Lattice Unity package (com.lattice.netcode),
//   2. address every REVIEW/MANUAL item in the matching .report.md,
//   3. wire spawn-by-type, input routing, and RPC payload marshalling.
// This file: 5 item(s) to review, 3 item(s) need manual work.
// =====================================================================

Step 2 — look at the before → after

The tool does the mechanical rewrites for you. Here are the real transforms on the sample.

Namespace and base class (Auto). using Fusion; becomes using Lattice;, and the base class is fully qualified:

using Fusion;
using UnityEngine;

public class PlayerController : NetworkBehaviour
{
    [Networked] public Vector3 Position { get; set; }
using Lattice;
using UnityEngine;

public class PlayerController : Lattice.NetworkBehaviour
{
    [Networked] public Vector3 Position { get; set; }

[Networked(OnChanged = ...)] → split attribute (Review). Lattice splits the change callback into a separate [OnChanged(...)] attribute. The handler signature differs — see the mapping table — so this is flagged Review:

[Networked(OnChanged = nameof(OnHealthChanged))]
public int Health { get; set; }
[Networked, OnChanged(nameof(OnHealthChanged))]
public int Health { get; set; }

RPC source+target → single target + authority guard (Review). Fusion's [Rpc(source, target)] becomes Lattice's single-target [Rpc(LatticeRpcTarget.X)]. The source (who is allowed to call) does not disappear — the report tells you to enforce it in the method body with a guard:

[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
public void Fire(Vector3 direction)
{
    if (HasStateAuthority)
        Runner.Spawn(projectilePrefab, Position, Quaternion.identity, Object.InputAuthority);
}
[Rpc(LatticeRpcTarget.Server)]
public void Fire(Vector3 direction)
{
    // report says: guard with `if (!HasInputAuthority) return;`
    // report says: Spawn(...) is left in place — register a NetworkType + build initial state
    if (HasStateAuthority)
        Runner.Spawn(projectilePrefab, Position, Quaternion.identity, Object.Owner);
}

Notice two things the tool did and did not do: it rewrote Object.InputAuthorityObject.Owner (Review), but it left Runner.Spawn(...) in place — the prefab+transform spawn model has no mechanical equivalent, so it is reported, not guessed.

Authority checks (Auto). Object.HasStateAuthorityHasStateAuthority (the Object. prefix drops because NetworkBehaviour exposes it directly).

Input struct interface drop (Review). struct PlayerInput : INetworkInput becomes a plain struct PlayerInput — Lattice has no INetworkInput marker; the interface is removed and the input-routing wiring is flagged as manual:

public struct PlayerInput : INetworkInput
{
    public Vector3 Move;
    public bool Fire;
    public bool Jump;
}
public struct PlayerInput
{
    public Vector3 Move;
    public bool Fire;
    public bool Jump;
}

Interface + sub-namespace removal (Review / Manual). On the launcher, MonoBehaviour, INetworkRunnerCallbacks, INetworkInputProvider collapses to plain MonoBehaviour (Lattice uses C# events on NetworkRunner, not a callbacks interface), and using Fusion.Sockets; is removed (no Lattice equivalent).

Step 3 — read the per-file report

Each file gets a *.report.md. The buckets mean different things — read them carefully:

  • Auto — mechanically rewritten; a faithful 1:1. Still compile + smoke-test, but no decision needed. (~50% of detected constructs on the sample.)
  • Review — rewritten, but the semantics, timing or signature differ. You must verify each one. "Mechanically handled" is not the same as "done".
  • Manual — no clean 1:1; the tool detects it, leaves it in place with a guidance comment, and reports it. You write the replacement.

Here is the head of Scripts/PlayerController.cs.report.md:

# Migration report: `Scripts/PlayerController.cs`

- Auto-converted (verify-not-required): 6
- Converted, needs review: 5
- Manual work required: 3

A representative Review finding — note it gives you the exact line, the before, the after, and what to do:

### Line 36 -- Rpc
- Source:  `[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]`
- Fusion:  [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
- Lattice: [Rpc(LatticeRpcTarget.Server)]
- Guidance: Lattice's [Rpc] takes ONE routing target (Server/Owner/All/AllButOwner). The Fusion
  SOURCE is enforced in code instead: Guard the body with `if (!HasInputAuthority) return;`. ALSO:
  Fusion auto-marshals RPC parameters; the Lattice binding carries a byte[] payload -- serialize
  args yourself in the body / OnRpc handler.

And a representative Manual finding — left in place, with the alternative spelled out:

### Line 29 -- Runner.DeltaTime
- Source:  `Runner.DeltaTime`
- Lattice: (fixed dt: 1.0/tickRateHz)
- Guidance: NetworkRunner has no DeltaTime in this binding. Use the fixed step you tick with
  (1.0/tickRateHz, or Time.fixedDeltaTime in the editor). Left unchanged.

Step 4 — read the summary

converted-output/MIGRATION-SUMMARY.md rolls everything up: counts, a per-file table, and a "Manual work, by kind" list so you can see your biggest rocks at a glance.

File Auto Review Manual
Scripts/GameLauncher.cs 1 3 3
Scripts/GameManager.cs 8 1 3
Scripts/PlayerController.cs 6 5 3
Scripts/PlayerInput.cs 1 1 0
Scripts/PlayerMovementProcessor.cs 14 10 1
Scripts/Projectile.cs 5 0 1

The summary also carries a KCC conversions section when --kcc adapter (the default) converts a processor — here Scripts/PlayerMovementProcessor.cs (KCCProcessorIKccProcessor), with the collision routing and any unmapped KCCData members listed as Review TODOs to finish by hand (search the converted files for TODO[fusion2lattice]).

Honest coverage estimate

On the common Fusion API surface the tool mechanically handles ~80% of detected constructs (on the sample, with --kcc adapter: (35 auto + 20 review) / 66 = 83%). Read that carefully:

  • "Mechanically handled" ≠ "done". The Review items are rewritten but have differing semantics/signatures you must verify — and that includes the bulk of a converted KCC processor (collision routing + unmapped members land in Review). Only the Auto items (~half of detected constructs) are faithful 1:1s.
  • The percentage is over detected constructs in idiomatic Fusion code. A project leaning heavily on networked collections, NetworkTransform/NetworkRigidbody, prediction-timing tricks, or AOI will skew toward Manual.
  • It measures API-surface mechanics, not behaviour. The hard parts of a netcode port — prediction/rollback timing, the spawn model, RPC marshalling, input routing — are deliberately reported, not auto-solved.

Concept mapping table

This is the full Fusion 2.1 → Lattice mapping the codemod implements (Auto/Review rows are rewritten; Manual rows are detected and reported but never rewritten). It is the same table that backs FUSION-MIGRATION-GUIDE.md.

Category Fusion 2.1 Lattice Confidence
Base class class X : NetworkBehaviour class X : Lattice.NetworkBehaviour Auto
Base class class X : SimulationBehaviour class X : Lattice.NetworkBehaviour Review
Lifecycle Spawned() / Despawned() Spawned() / Despawned() Auto
Tick FixedUpdateNetwork() FixedUpdateNetwork() Review
Delta time Runner.DeltaTime (double) dt passed to Tick / Runner fixed step Manual
Replicated property [Networked] public int Health { get; set; } [Networked] public int Health { get; set; } Auto
Change callback [Networked(OnChanged = nameof(H))] [Networked][OnChanged(nameof(H))] Review
Interpolation hint [Networked] + [Interpolation] [Networked(Interpolated = true)] Review
Networked collections NetworkLinkedList<T> / NetworkArray<T> / NetworkDictionary<,> (none — fixed POD state block) Manual
RPC [Rpc(RpcSources.X, RpcTargets.Y)] void Foo() [Rpc(LatticeRpcTarget.Y)] void Foo() (+ Runner.Rpc/Object.Rpc) Review
RPC parameters RPC method with parameters (sent automatically) byte[] payload (manual marshal) OR a custom event Manual
RPC info RpcInfo info parameter (sender available via OnRpc / OnEvent args) Manual
Authority check Object.HasStateAuthority / HasStateAuthority HasStateAuthority Auto
Authority check Object.HasInputAuthority / HasInputAuthority HasInputAuthority Auto
Authority id Object.StateAuthority / Object.InputAuthority (PlayerRef) Object.Owner (ulong) / Runner.LocalPlayer Review
Authority transfer Object.RequestStateAuthority() (shared) Object.RequestAuthority() / Runner.RequestAuthority(netid) Review
Spawn Runner.Spawn(prefab, position, rotation, owner) Runner.Spawn<T>(NetworkType, in T initialState, ulong owner) Review
Despawn Runner.Despawn(Object) / Runner.Despawn(netObj) Runner.Despawn(NetworkObject) / Runner.Despawn(netid) Auto
Find object Runner.FindObject(id) / Runner.TryGetNetworkObject Runner.Find(netid) Review
Startup runner.StartGame(new StartGameArgs { GameMode = ... }) runner.StartGame(LatticeGameMode.Host, port) Review
Runner creation AddComponent<NetworkRunner>() / runner prefab new NetworkRunner(tickRateHz) (headless) or AddComponent (editor) Review
Input struct struct X : INetworkInput struct X (plain POD) Review
Input read GetInput(out MyInput input) GetInput<T>(out T input) (binding helper; see manual notes) Manual
Input provider INetworkInputProvider / OnInput(runner, input) / AddInputProvider (runner input-poll callback in host) Manual
Networked transform NetworkTransform / NetworkRigidbody / NetworkRigidbody2D [Networked] Vector3 Position / Quaternion Rotation + apply in StateUpdated() Manual
Interest management AreaOfInterest / SetPlayerAlwaysInterested / AOI (core interest management; no per-object AOI API in binding) Manual
Runner callbacks INetworkRunnerCallbacks (OnPlayerJoined/Left/…) NetworkRunner.OnConnected/OnDisconnected/OnSpawned/OnDespawned/OnRpc/OnEvent events Review
Tick / prediction timing Runner.Tick / Tick.Raw / prediction & rollback authority-driven tick + snapshot interpolation Manual
KCC processor class X : KCCProcessor / NetworkKCCProcessor (+ KCCData member access) class X : IKccProcessor (+ KccData member mapping) via --kcc adapter Review (scaffold)

The four big rocks

These are the most labour-intensive parts of any real Fusion → Lattice port, and the tool flags them rather than guessing:

  • Spawn model. Fusion spawns a prefab + transform; Lattice spawns a registered NetworkType
  • an initial POD state block. Register the type once (Runner.RegisterType), build the initial-state struct (position/rotation become [Networked] fields you set), then Spawn<T>(type, in state, owner).
  • RPC payloads. Fusion auto-marshals RPC args; the Lattice binding carries a byte[] payload you serialize/deserialize yourself in the method body / OnRpc handler. The arity also differs: Fusion's (source, target) becomes a single LatticeRpcTarget plus an in-body authority guard.
  • Input routing. Lattice has no INetworkInput interface and (in this binding) no typed GetInput<T> on NetworkBehaviour. Input collection is a host concern (the on_input_poll callback in the C ABI); move your OnInput logic into the runner host's poll.
  • Prediction / tick timing. Fusion forward-predicts and resimulates on the client; Lattice predicts in the core with snapshot interpolation on remotes. Any logic that depended on Fusion's per-tick resimulation, tick numbers in resim, input-buffering windows, or reconciliation must be re-reasoned by hand.

Post-conversion: next steps

  1. Reference the Lattice Unity package (com.lattice.netcode) in your project so the Lattice.* types resolve.
  2. Work the Review items. Each is rewritten but needs a decision — verify the [OnChanged] handler signature, add the authority guards the RPC findings call for, confirm Object.Owner semantics, and rework StartGame into StartGame(mode, port) (session/scene/player-count move to the director/lobby layer).
  3. Hand-do the Manual items. Replace networked collections with a fixed-capacity array or a serialized Bytes field; replace NetworkTransform with [Networked] position/rotation applied in StateUpdated(); move input collection into the host poll; marshal RPC payloads; re-reason any prediction/tick-timing logic.
  4. Compile against the binding, fix what the compiler finds, then test — start with a smoke test of the Auto-converted paths, then validate behaviour on the Review/Manual paths.

Pair this with the dev skill

The lattice-gamedev Claude Code skill teaches an AI assistant the Lattice mental model and the real lattice.h API surface — useful when you are hand-finishing the Review/Manual items, since it knows the exact spawn / RPC / input / authority idioms the report points you toward.

Reference

  • Tool source, sample, and committed converted output: tools/fusion2lattice/
  • Generated concept guide: tools/fusion2lattice/FUSION-MIGRATION-GUIDE.md
  • Lattice engine integration (the target idiom): design 05 — Engine Integration
  • The C ABI the binding wraps: API Reference