We ship one Unity WebGL build. It serves two applications that share almost no user-facing behaviour.
One is a lab player: it loads an authored document, steps a student through an experiment, animates objects along curves, talks to a physics backend, and renders the results. The other is a scene editor: spawn an object, drag it, select it, lock it, undo, place it against bounds, save.
Different interaction models, different users, different data. Same binary. They were separate Unity projects until we merged them, and the merge is one of the better decisions in this codebase — not because sharing is inherently good, but because of what we chose not to share.
What was already common
The argument for merging was not "two projects is untidy". It was a list of things that had been independently maintained twice:
- glTF loading and instantiation, including material replacement at import time
- the service registry and its bootstrap sequence
- the selection-outline rendering domain
- the desk-frame coordinate convention and its conversions
- the entire WebGL build and deployment pipeline
Two of those had already drifted. Fixing a material-replacement bug meant fixing it twice, and the second fix happened weeks later because nobody remembered the second copy existed.
That is the honest trigger. Not architecture — maintenance.
The engine inside the engine
Unity is the engine. What the merge produced is a second, smaller engine sitting on top of it: a substrate that both applications instantiate rather than reimplement.
It consists of the object manager (loading, parenting, material policy), the service registry, the outline domain, the desk-frame conventions, and a single always-present JavaScript bridge object. None of it knows which application is running. All of it is used by both.
Above that substrate the two applications diverge completely, and the merge was successful precisely because we did not try to make them converge.
The mode gate is one method
Switching applications is not a scene load. Both applications' scene roots exist in the same scene; one of them is turned off.
[DefaultExecutionOrder(-95)]
public class SpatialActivator : MonoBehaviour, IService
{
[SerializeField] private GameObject _spatialRoot;
[SerializeField] private GameObject[] _unnecessaryGameObjects;
[SerializeField] private MonoBehaviour[] _unnecessaryComponents;
public void Activate()
{
foreach (var go in _unnecessaryGameObjects) go.SetActive(false);
foreach (var c in _unnecessaryComponents) c.enabled = false;
Instantiate(_spatialRoot);
}
}
That is the whole gate. Disable the player's roots and components, instantiate the editor's prefab root.
The execution order matters: -95 runs after the main bootstrap at -100, so the shared services are registered before either application's roots come up.
The important design decision is the one that is easy to miss. The JavaScript bridge object is not in either mode's root. It is created at startup, marked DontDestroyOnLoad, and stays on regardless of which application is active. If it lived inside a mode's root, switching modes would destroy the object that receives the message telling it to switch modes. You get in and cannot get out.
Anything that controls a mode must live outside every mode. Obvious in hindsight; not obvious while drawing the hierarchy.
There is a third mode, incidentally — a model testing stand, selected by a global the JavaScript page sets before boot. Same mechanism.
Two composition roots, one registry
The shared bootstrap registers the substrate. The editor has its own bootstrap that registers its services — investigation management, selection, locking, undo history, the transformation pipeline, bounds placement, pointer handling — into the same registry.
It runs after the main bootstrap, which is what makes the layering work: the editor's services can resolve the shared object manager because the shared one registered first.
And in player mode, the editor's services are registered but dormant. Nothing in the active scene resolves them. They sit in the registry costing a dictionary entry each.
I like this more than I expected to. There is no conditional registration, no per-mode installer, no scoping. The registry is a flat namespace, both applications populate it, and activity is determined by which roots are ticking rather than by what is registered. A dormant service is a cheap thing to have.
The part I would defend hardest: two transports
This is the decision that makes the merge work, and it is the one that sounds wrong when you say it out loud.
The two applications communicate with JavaScript in completely different ways, and we kept it that way on purpose.
The player uses shared memory. A pair of flat float arrays in the WebAssembly heap, read and written every frame, carrying object poses, camera state and step signals at 60 Hz with no serialization. It is a continuous-state channel, and it is that way because the player's whole job is continuous: animate this along a curve, track that object's screen position, interpolate the camera.
The editor uses messages. JavaScript calls into Unity with a method name and a JSON payload; Unity posts events back out to the page. Spawn this object. Move that one. Selection changed. Nothing runs at 60 Hz because nothing needs to — an editor is a sequence of discrete commands with pauses between them, driven by someone dragging a mouse and then stopping.
Unifying these would have been the "clean" move. It would also have been wrong in both directions. Putting the editor on the shared buffer means inventing a command encoding in floats. Putting the player on messages means serializing forty objects' poses to JSON sixty times a second, which is exactly the thing the buffer exists to avoid.
The lesson generalises past this codebase: shared substrate does not imply shared interface. The merge was about not maintaining two glTF loaders. It was never about making a scene editor and a lab player talk to the browser the same way, and the moment you conflate those two goals you have made the merge harder than it needs to be and produced a worse result.
How one receiver serves many domains
Both applications and every feature domain send messages to a single Unity object. Unity's message dispatch resolves by method name across every component attached to that object, which means two features can collide on a generic verb like Apply or Create.
The convention is that every domain prefixes its methods with the domain name. OutlineApply, MarkerCreate, SpawnInstances, SpatialSetMode, SpatialOutlineApply. Each domain is a separate component on the same object; the prefixes are the only thing preventing collisions.
It is a naming convention doing the work a namespace would do in a language with namespaces at this layer. It has held across five domains. The cost is that the convention is unenforced — nothing stops someone adding a bare SetMode — and the mitigation is that the rule is written down in the merge record and every existing method follows it.
The outline domain shows why the split is worth the discipline. Global outline appearance is configured through the shared domain, because it applies to everything. Per-entity attach and detach for editor entities goes through the editor's bridge, because resolving an entity id requires the editor's own registry. Same visual feature, two entry points, split along the line of who owns the identifier.
What it costs
Every build carries both applications. A student loading the lab downloads the editor's code and never runs it. Unity's stripping removes what it can prove is unreachable, but both applications are reachable — the mode gate is a runtime decision, so nothing is provably dead. For a download-size-sensitive target this is a real, permanent tax.
I would take that trade again at two applications sharing five subsystems. I would think much harder at two applications sharing one.
Mode state is global. The gate disables a list of objects and components assigned in the inspector. That list is maintained by hand. Add a player-side manager and forget to add it to the disable list, and it keeps ticking underneath the editor. The failure is quiet — some behaviour from the wrong application, running invisibly.
A better version would invert this: mark the roots and derive the list. We have not done it because the list has changed roughly twice since the merge.
Two wrappers, two protocols, two sets of documentation. One binary did not become one thing to learn. The editor's wrapper has its own architecture — a whitelist dispatcher, a local mirror of the scene so the host can query without a round trip, a pure conversions module tested under node --test. That is all separate from the player's wrapper. The merge unified the runtime, not the surface area a new developer has to absorb.
What generalises
The reusable idea is picking the seam.
We shared the layer where duplication had already caused a real bug we could name — asset loading, service wiring, the coordinate convention, the build. We left alone the layer where the two applications genuinely differ: how they talk to the browser, how they model interaction, what their frame budgets look like.
The failure mode I have watched elsewhere is merging in the other direction: unifying the interfaces because they look similar on a whiteboard, while leaving two copies of the loader because that part is "just plumbing". You get an abstraction that fits neither caller and the maintenance problem you started with.
The test I would use now: merge the layer where a bug fixed once should be fixed everywhere. Keep separate the layer where the two callers would want different answers. Those are usually not the same layer, and the second one is usually the one that looks more shareable.