A step in my virtual lab looks simple from the outside. The scene settles, the camera moves somewhere useful, the student does something, objects animate to where they belong, a physics simulation runs, and the camera goes back.
Internally that is ten phases, and the sequence runs across a language boundary. The orchestrator is JavaScript. The 3D runtime is C# compiled to WebAssembly. Some phases are decided entirely by one side, some entirely by the other, and the interesting design work was in making all of them look the same to the code that sequences them.
Phases are conditional, not fixed
The first thing that surprised me is that "ten phases" is a maximum, not a script. Most steps run four or five.
Each phase is its own module, and they are tiny. Here is one in full:
/**
* Camera UI phase: lerp camera to the position prescribed by the step's
* camera_ui field. Gives the user a good angle for the upcoming gesture.
*/
export const PHASE_NAME = 'camera_ui';
export function shouldRun(entries) {
return entries.some(e => e.cameraUi != null);
}
That is the whole file. Nine of them, between eight and thirteen lines each. A phase declares its name and answers one question: given this step's content, do I run at all?
A step with no interaction skips the gesture phase and auto-advances. A step that never touches the camera skips all three camera phases. A step whose objects have no targets skips the match phase.
The result is that the phase list is not a pipeline every step marches through. It is a filter applied to the step's own data, which means adding a new authoring field means adding one shouldRun and nothing else changes.
Three ways for a phase to finish
Here is where the border shows up. The phases share an interface and share almost nothing else.
Immediate. The freeze phase is a beat where the step has begun and nothing has moved. Its execute is an empty function with a comment saying so. It exists because other systems need a defined moment to hang off — the undo cache captures the scene during freeze, precisely because it is the one point in a step guaranteed to be quiet.
JavaScript-driven, per frame. The trajectory phase animates an object along a Bézier curve. JavaScript owns this completely: it samples the curve each frame and writes the resulting pose into the shared override buffer that the 3D runtime reads at the end of its update. Nobody is asked for permission. The phase finishes when the curve is exhausted.
Runtime-driven, over an unknown number of frames. The simulation phase posts the world to a physics backend and plays back the response. JavaScript cannot know how long that takes — it depends on the network, the solver, and the length of the simulated window. It cannot poll a float in a buffer for this, because "done" is not a number, it is an event with a payload.
That third category is why there is a protocol.
The buffer cannot say "wake me when you are done"
The per-frame channel between the two sides is a pair of flat float arrays in shared memory. It is superb at continuous state — poses, camera, time — and structurally incapable of expressing completion with a result.
You could fake it. Reserve a float, have the runtime set it to 1 when finished, poll it every frame. People do this, and it works right up until you need to know which thing finished, or you need a payload attached, or two things can finish in the same frame. Then you are inventing a message queue out of floats, badly.
So long-running phases use the other channel — the one built out of two one-way string pipes — and JavaScript gets a promise:
start(phaseName, payload) {
return new Promise((resolve) => {
this.#pending = { phase: phaseName, resolve };
const json = JSON.stringify({ phase: phaseName, ...payload });
this.#engine.sendMessage('JsBridge', 'OnPhaseStart', json);
});
}
Outbound goes over the runtime's own string-message entry point. Inbound arrives through the WebAssembly callback shim as a JSON string, which lands on a handler that matches it against the pending phase and resolves.
Neither pipe knows anything about requests or responses. The request/response semantics are entirely a JavaScript-side construction: a stored resolver and a name to match against.
The protocol has four legs, and each one earns its place
JavaScript sends Start. The runtime acknowledges with Started. When the work is done, the runtime sends Complete with a result. JavaScript then sends End, and the runtime confirms with Ended.
The obvious question is why Start and Complete are not sufficient. Two reasons, both learned rather than designed.
Started is an entry acknowledgement. It confirms the runtime received the message, parsed it, and entered the phase — as distinct from having dropped it, or not having a handler for it. Without it, a phase that never begins and a phase that begins and takes a long time are indistinguishable from the outside. With it, the failure has a shape.
End / Ended is the cleanup leg, and it exists because completion and teardown are not the same moment. The runtime finishing a simulation does not mean JavaScript is finished with the results — it still has to apply them, transition the scene, decide what happens next. End says: I have consumed the result, you may release what you were holding. Merging that into Complete means the runtime tears down while JavaScript is still reading.
The undo flow uses the same split for the same reason, and it is where the split visibly matters: the finish signal is deliberately delayed by two animation frames so that pose writes land before the runtime computes anything derived from them.
One pending slot, deliberately
The channel holds exactly one pending phase:
#pending = null; // { phase, resolve, reject }
Not a map, not a queue. A single slot. Starting a second phase while one is in flight overwrites it.
That looks like a bug and is a constraint. A step is strictly sequential — freeze, then camera, then gesture, then trajectory, then simulation — and there is never a legitimate reason for two phases to be in flight at once. Making the channel physically incapable of tracking two is how that invariant is enforced. If I ever genuinely need concurrent phases, the single slot will fail loudly and immediately rather than interleaving two step sequences.
There is a matching guard on the inbound side:
if (msg.phase !== phase) return;
Messages that do not name the pending phase are dropped. This is not paranoia — it is the abort case. When a student interrupts a step mid-simulation, the runtime may still be holding messages for the phase we have walked away from. They arrive after we have moved on, name a phase nobody is waiting for, and get discarded silently. Without that line, a late Complete from an abandoned phase resolves whatever happens to be pending now.
Abort resolves; it does not reject
abort() {
if (this.#pending) {
this.#pending.resolve({ phase: this.#pending.phase, result: null, aborted: true });
this.#pending = null;
}
}
An interrupted phase is not an error. The student pressing Back during a simulation is a normal thing to do, and modelling it as a rejection means every await needs a try/catch whose only job is to recognise that nothing went wrong.
Instead the promise resolves with aborted: true and callers branch on it. Exceptions stay reserved for actual failures, and the happy path stays free of defensive scaffolding.
I would defend this generally: user-initiated cancellation is a normal outcome, not an exception. The moment you model it as one, you teach everybody to write catch blocks that swallow, and eventually a real error gets swallowed with it.
Nine tiny files and one enormous one
The phase modules total about ninety lines between them. The machine that runs them is over two thousand.
That asymmetry bothered me for a while. The instinct is to push behaviour down into the phases — give each one an execute, let it own its logic, shrink the machine.
I tried it on paper and stopped. The reason the machine is large is that most of what it does is not per-phase, it is cross-phase. A rotation interpolation that has to span both the return trajectory and the bridge lerp, continuously, because the object must not visibly re-orient at the seam. A trajectory player that stays seekable after it completes, so scrubbing still works. A set of object ids currently under runtime control, so the JavaScript side knows not to fight it. A camera pose captured at freeze and reused three phases later.
None of that belongs to a phase. It belongs to the sequence, and pushing it into phase modules would mean each phase reaching into its neighbours — which is a worse coupling than a large file, because it is a distributed one.
So the split I actually landed on is: phases declare, the machine performs. A phase file answers "should I run" and names itself. Everything about how work is done, and everything that spans a boundary between phases, lives in one place where you can read it in order.
The honest cost is that the machine is a two-thousand-line file, and that is not a comfortable number. What makes it tolerable is that it reads sequentially — it is long because the sequence is long, not because it is tangled. What would make it intolerable is if the phases started needing to know about each other.
What I would change
The phase names are strings and they are matched by string comparison at four sites. That has not bitten me, but it is exactly the kind of thing that bites the week after you write an article saying it has not.
And the two-frame delay in the undo finish deserves to be a real signal rather than a timer. It is documented, it is understood, and it is still a requestAnimationFrame sandwich standing in for an acknowledgement the runtime could send me directly. The protocol already has four legs. There is no principled reason it cannot have a fifth where one is genuinely needed.
Everything else I would build the same way. The valuable idea, if there is one to take away, is that a phase abstraction is worth having even when the phases have nothing in common underneath — one is a no-op, one is a per-frame animation loop, one is a network round trip through another language. The sequencer does not care. It asks each phase whether it applies, runs it, and waits for it to say it is finished. How it finishes is the phase's business.