Short answer: Do not rewrite it. On a mature XR codebase the legacy interaction path is usually the only path that has been proven on device, and replacing it converts a system whose bugs you know into a system whose bugs you have not met yet — typically weeks before a fixed date. Migrate incrementally instead: move the platforms that are cheap to verify without a headset, freeze the controller path that already works, route every new feature through one modern dispatch mechanism, and isolate the old code rather than extending it. Then enforce the rule that actually prevents the regressions, which is not about input systems at all — exactly one system may write a given transform.

Six systems, all partly responsible for input.

The hardest engineering problem on a mature XR project is rarely the new feature. It is that the new feature has to be added to a codebase in which several generations of interaction code already believe they are responsible for what the user just did.

On the A2RL VR application the input surface had accumulated over years of active development, and by the time we came to extend it the stack looked like this:

  • legacy collider-based interaction, written before any SDK existed for it,
  • custom raycast handlers built on top of that,
  • OVRInput for Quest controllers,
  • Unity UI, with both Graphic Raycasters and Physics Raycasters,
  • the Meta Interaction SDK,
  • the Unity Input System,
  • and the Unity Event System underneath parts of it.

None of these was wrong when it was added. Each was the reasonable choice at the time. The problem is what they become collectively.

What "the user pointed at that" could mean Desktop / mouse Quest controller Hands / gaze | | | v v v Graphic Raycaster OVRInput Interaction SDK Physics Raycaster Custom ray handler Unity Input System | | | +-----------+-----------+---------------------+ | Unity Event System | ...and a layer of collider handlers written before any of the above existed, still live, still receiving events

The practical symptom is a codebase where cause and effect stop being local. A change to a UI panel alters VR controller behaviour. Desktop input behaves differently from Quest for reasons written down nowhere. A fix that is obviously correct in the editor is obviously wrong on the headset.

Why a rewrite is the wrong instinct.

The instinct, faced with that diagram, is to delete most of it and start again with one modern system. It is the wrong instinct on a project that has shipped, and it is emphatically the wrong instinct on a project with a fixed public date.

The reason is that the legacy path is not merely old code. It is the only code that has been tested by real users on real hardware. Every strange conditional in it is usually a bug somebody hit and fixed. A rewrite discards that accumulated correctness and keeps only the parts of the behaviour someone thought to write down — which, on a codebase of that age, is a small fraction of it.

A rewrite trades a system whose bugs you already know for a system whose bugs you have not met yet.

There is a second reason, and it is commercial rather than technical. A rewrite has a cost you can estimate and a risk profile you cannot. Incremental migration has a cost you can estimate and a risk profile that stays bounded, because at every point the previous behaviour is still there to fall back to. On a project delivering into a live international event, bounded risk is worth considerably more than architectural tidiness.

The rule we migrated by.

The strategy came down to a single question asked of each layer: how expensive is it to prove this still works?

Anything verifiable on a desktop machine in seconds was cheap to move. Anything that could only be verified by building to a headset, putting it on and exercising it by hand was expensive — and expensive-to-verify code is exactly the code you should not be touching before a deadline.

LayerDecisionReason
Quest controller input (OVRInput)FreezeAlready stable and proven on device, and the only path a live audience would ever touch. Nothing to gain, a great deal to lose.
Desktop and mobile inputMigrate to the Unity Input SystemVerifiable in the editor in seconds. Rebinding, multiple device types and cleaner action mapping arrive as a side effect.
Any newly added interactionBuild on the Unity Event SystemOne dispatch path for everything added from that point on, so the sprawl stops growing even while it still exists.
Grab, poke and hand interactionsAdopt the Meta Interaction SDK selectivelyOnly where it replaced custom code outright. Never alongside it — two systems handling the same gesture is worse than either alone.
Legacy collider handlersIsolate, do not extendLeft working and left alone. No new code was permitted to depend on them, so the surface stopped widening.

That last row does the real work over time. Freezing a legacy system is not the same as tolerating it. A frozen system has a fixed, known blast radius and shrinks as features are replaced around it. A merely tolerated one keeps acquiring new callers and never goes away.

A worked example: the jitter that only appeared on dolly.

The most instructive bug on the project had almost nothing to do with input systems, and everything to do with the failure mode underneath them.

The symptom: floating VR windows jittered when pushed forwards and pulled backwards on the thumbstick. Rotation was clean. Dragging with the controller was clean. Only the dolly axis was unstable, and only while the thumbstick was held.

That asymmetry is the entire clue. If a transform is broken, it is usually broken in every axis. When exactly one axis misbehaves, the axis is not the problem — the intersection is.

Two writers, one transform Controller ray --> Ray follow --> DOTween DOMove ---+ | +--> panel.transform | Thumbstick -----> Dolly step --> position += fwd ---+ Frame N tween writes toward its target Frame N+1 thumbstick adds its own delta Frame N+2 tween corrects back toward a target that was computed before the thumbstick moved anything

Two systems were writing the same transform every frame: the tween that made panels follow the controller ray smoothly, and the direct positional update from the thumbstick. Rotation and drag looked fine because only one system wrote those. Dolly was the single axis both touched, so it was the only place the fight became visible.

The fix was not to tune the tween, damp the input or add a dead zone — all of which would have hidden the symptom while leaving the cause in place. We removed the competing write. Movement became controller-relative through a persistent local offset, so the panel position is derived from one authority each frame rather than accumulated from two. The jitter did not improve. It disappeared.

One writer per transform.

Generalised, the rule that came out of that is the one worth taking from the whole migration, and it is broader than input:

Exactly one system may write a given transform, and you should be able to name it without reading the code.

The reason this bites so often in XR specifically is that the list of things which quietly write transforms is longer than most people hold in their head at once:

  • tweening libraries, which are writers even though they read like animations,
  • physics, the moment a Rigidbody exists on the object,
  • constraints, IK and look-at behaviours,
  • animation clips carrying position curves,
  • network synchronisation,
  • camera rig and tracking-space code that reparents things,
  • and any script at all with a line assigning to transform.position.

When two features genuinely need to move the same object, the correct shape is that one of them changes an input to the owner rather than writing the transform itself. The thumbstick should adjust the offset the follow behaviour targets; it should not move the panel. That is a small refactor, and it makes an entire class of jitter, drift and snap-back bugs structurally impossible rather than intermittently absent.

What incremental migration actually costs.

It is worth being honest about the bill, because the approach is not free.

You carry two input paths at once, and everyone on the project has to hold a map of which platform runs which. New starters need that map explained rather than discovered. The codebase is, for a period, measurably less elegant than either the system you started with or the one you are heading towards. If you like clean architecture, it is uncomfortable to live in.

What it bought, in exchange, was that the platform which mattered on the day never regressed, and that each subsequent piece of interaction work was cheaper than the one before it — because the modern path had grown by then, and the frozen legacy island had shrunk.

On a project without a hard date the calculus can differ, and a rewrite is sometimes the right call. On anything shipping into a live event, a broadcast window or a public installation, the version that works is worth more than the version that is correct. Incremental migration is how you reach the second one without ever giving up the first.

What this means for a buyer.

Start with the business decision, audience, and evidence the project must produce. Simam Digital can turn that into a focused discovery, prototype, MVP, or production roadmap across AI applications, SaaS platforms, digital twins, real-time 3D, XR, and interactive systems.

Sources and further reading