Rocket Workshop — Toggling Between Assembly and Flight in a Single Canvas
Toggling between assembly UI and flight simulation inside one canvas. Plus the AudioContext lifecycle and ResizeObserver redraw traps that showed up along the way.
Opening — Two Canvases or One Round-Trip?
Rocket Workshop has two modes.
- Assembly — pick engines, fuel tanks, payloads, boosters; review budget and specs
- Flight — run the actual launch, ascent, and orbit insertion sim
The natural design choice: two separate canvases, toggling via display property. But that keeps two large canvases in the DOM at all times, with duplicated background and scaling logic.
So I went with a single-canvas round trip. This post covers the traps along the way.
Mode Toggle Structure
A single React state manages the whole mode:
const [mode, setMode] = useState<'assembly' | 'flight'>('assembly');
const phaseRef = useRef<WorkshopPhase>('assembly');
- Assembly mode: paper panel UI + rocket preview + HUD preview on the canvas. No RAF loop — static render + ResizeObserver for resize.
- Flight mode: full-screen canvas + RAF loop (60fps cap) + phase-based simulation.
The same canvas serves as both "static UI canvas" and "game canvas" alternately.
Trap 1 — AudioContext Lifecycle
My first implementation called ctx.close() in the useEffect([phase]) cleanup.
// ❌ problematic
useEffect(() => {
const ctx = new AudioContext();
// ... engine rumble start
return () => ctx.close(); // close on cleanup
}, [phase]);
Symptom: during flight, engine sound cut out every time the phase changed (countdown → launch → ascent).
Cause: useEffect cleanup runs on every phase transition, not just unmount. Every phase change destroyed and recreated the AudioContext, so the engine sound couldn't carry across.
Fix: handle AudioContext lifecycle only at explicit points.
// ✅ fixed
// phase cleanup doesn't close
useEffect(() => {
// per-phase effects only; no close() here
}, [phase]);
// unmount-only effect
useEffect(() => {
return () => stopEngineRumble(); // close only on unmount
}, []);
// explicit close on returning to assembly
function returnToAssembly() {
stopEngineRumble();
setMode('assembly');
}
Principle: close global resources only at explicit "tear-down moments". Don't let deps-driven auto-cleanup handle them.
Trap 2 — ResizeObserver and Static Rendering
Assembly has no RAF loop. It draws once on mount with drawPreview() and stops. But the view didn't update on window resize.
ResizeObserver Needed
useEffect(() => {
if (mode !== 'assembly') return;
const canvas = canvasRef.current;
if (!canvas) return;
// initial draw
drawPreview(canvas);
// respond to resize
const ro = new ResizeObserver(() => {
drawPreview(canvas);
});
ro.observe(canvas.parentElement!);
return () => ro.disconnect();
}, [mode]);
Setting canvas.width Clears It
Another thing to watch: canvas.width = X wipes the canvas entirely. In a static renderer, forgetting this leaves you with "screen went blank after resize" bugs.
RAF Protection
ResizeObserver callbacks can fire very rapidly. Calling drawPreview on every callback is wasteful. Wrap in RAF:
let rafId: number;
const ro = new ResizeObserver(() => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => drawPreview(canvas));
});
Trap 3 — Mode Transition Timing
When going Assembly → Flight, clearing the canvas, resetting physics, and starting RAF all need to happen in a single frame.
function startFlight() {
// 1. reset physics state
stateRef.current = createInitialState();
// 2. switch mode
setMode('flight');
// ↑ afterwards, useEffect starts the RAF loop
}
// flight-mode useEffect
useEffect(() => {
if (mode !== 'flight') return;
let rafId: number;
function loop() {
tick(stateRef.current);
drawFlight(canvasRef.current, stateRef.current);
rafId = requestAnimationFrame(loop);
}
loop();
return () => cancelAnimationFrame(rafId);
}, [mode]);
Key: reset state before setMode. That way the first RAF frame after the transition starts from a clean state.
Trap 4 — Missing Ref Resets on Flight Reset
When the user hits "Retry" for a new flight, every time I added a new ref, I had to reset in three places.
returnToAssembly()— on returning to assemblyresetFlight()— on retrycreateInitialState()— the init factory
For example, adding a peakAltM "peak-altitude-during-flight" ref, forgetting any of those three means previous flight's record bleeds into the second flight.
Keeping a checklist habit for new state fields was essential.
HUD Fold Animation
In-flight I want a compact HUD; on the result screen I want it expanded. An hudFold value (0 = open, 1 = folded) lerp-interpolated each frame:
// per frame
state.hudFold = lerp(state.hudFold, state.hudFoldTarget, 0.12);
Default Fold State per Phase
- assembly: folded (1)
- standby / countdown: open (0, auto slide)
- launch / ascent / coast: folded
- orbit / result / failed: keep current state — user can toggle manually
Phase change moves target to the default, but result phases respect the user's manual choice.
Fixed vs Foldable Regions
Split the HUD into "fixed region (radar + flight data + toggle bar)" and "foldable region (rocket specs + buttons)". Only the foldable part changes height, keeping the animation smooth.
Retrospective
Single-canvas round-trip kept DOM complexity low but lifecycle and state management harder. In particular, AudioContext and ResizeObserver both demanded clear definitions of "when do we close vs open".
If I were designing again, which pattern wins depends on project size.
- Single canvas round-trip: light DOM, complex lifecycle (this project's choice)
- Two separate canvases: heavier DOM, simpler lifecycle
This time the two canvases would have been similar in usage, so consolidating made sense. It also enabled bold transitions like "swap the whole screen in one frame".
If you're making a complex mode-switching game, I'd recommend adopting explicit lifecycle management + ref-reset checklists early.
Guestbook
Leave a short note about this post
Loading...