Most conversations about AI coding tools focus on how quickly they can generate a landing page or scaffold an application. I wanted to explore what happens when the project involves more demanding engineering: browser-based 3D, scientific data, coordinated animations, asset processing, state management, and interactions that need to work across different devices. That project became Loupe, an interactive digital museum I’ve been building with Claude and Codex. Visitors can explore planetary worlds, inspect dinosaurs, investigate a jet engine, follow human evolution, and experience Apollo 11’s lunar descent. For developers, the interesting part is underneath those experiences: how content, application state, rendering, and user input work together—and how AI coding agents can help build and refine that system. **The stack and why it fits **Loupe uses: Technology Role Next.js and React Routes, page composition, and interactive interfaces TypeScript Contracts between content, state, and rendering Three.js Geometry, materials, lighting, cameras, and shaders React Three Fiber Composing Three.js scenes through React Drei Model loading, orbit controls, and other scene utilities GSAP Motion and animation tooling Zustand Shared interaction state Vitest Content, calculation, state, and component tests Playwright Browser-level interaction tests Vercel Hosting The central architectural problem is coordinating two different systems. React handles choices such as selecting a planet, opening an evidence panel, or changing a viewing mode. The 3D renderer handles continuously changing properties such as camera position, rotation, and shader time. Those systems need to communicate without forcing the entire interface to update every time an object moves. Separating content, logic, and presentation Loupe’s repository separates much of its exhibit content from the components that display it. For the planetary exhibit, the structure includes: content/space/ atlas.ts atlas-assets.ts atlas-asset-licenses.json lib/space/ atlas-schema.ts atlas-store.ts atlas-scale.ts world-focus.ts surface-model.ts surface-lighting.ts components/space/ AtlasExperience.tsx AtlasStage.tsx AtlasCanvas.tsx AtlasFallback.tsx Each layer has a different responsibility. The content layer describes the worlds, available modes, hotspots, and assets. The logic layer resolves questions such as how large two worlds should appear in a comparison, where a selected feature lies, and which surface treatment a mode requires. The component layer turns those decisions into controls, explanatory panels, and rendered objects. That separation is particularly useful when working with AI agents. A task can target a specific responsibility: Fix how a selected planetary feature is oriented toward the camera, while preserving the content and comparison controls. This is easier to investigate and review than a broad request to “improve the planet page.” How the browser renders a world In Atlas of Worlds, the 3D stage combines procedural geometry with loaded textures and selected model assets. A globe can be represented by a sphere. Its appearance then comes from the surface map, material, lighting, orientation, and additional layers. Different modes add different representations: interior layers, rings, magnetic-field guides, or mission-related geometry. This is an important engineering choice. A detailed appearance does not always require a complicated imported mesh. For a roughly spherical object, much of the visible detail belongs in the texture and material. The canvas configuration also makes rendering choices explicit: <Canvas camera={{ position: [0, 0, 3.15], fov: 39, near: 0.05, far: 80, }} dpr={[1, 1.7]} gl={{ alpha: true, antialias: true, powerPreference: "high-performance", preserveDrawingBuffer: false, }} > <Suspense fallback={null}> <Scene {...props} /> </Suspense> </Canvas> This excerpt simplifies the surrounding component, but retains the relevant rendering settings. Capping device pixel ratio limits how many pixels the GPU must process on high-density screens. That matters because increasing both rendering dimensions increases the pixel workload quadratically. Camera framing, material settings, and texture quality then determine whether that budget produces a convincing result. State coordinates the whole exhibit Selecting a world affects more than the central object. It changes the available modes, selected features, lighting defaults, comparison state, camera commands, and explanatory content. Atlas uses a Zustand store to coordinate those changes. Its state includes fields such as: type AtlasState = { worldId: WorldId; activeModeId: string; selectedHotspotId: string | null; lightingMode: "natural" | "survey"; motionEnabled: boolean; reducedMotion: boolean; compareOpen: boolean; compareWorldId: WorldId; cameraCommand: { type: "idle" | "zoom-in" | "zoom-out" | "reset"; sequence: number; }; }; This is an abbreviated version of the actual state model. When the visitor changes worlds, the store resets the active mode to the new world’s default, clears the selected hotspot, and issues a camera reset. Doing this in one transition prevents combinations such as a selected feature belonging to the previous planet. The command sequence number is a small but useful detail. Clicking “reset” twice should produce two commands even though the command’s type remains "reset". It gives repeated actions a distinct identity. Keeping continuous motion out of React state For frame-by-frame movement, the renderer uses references and React Three Fiber’s useFrame. A mesh can rotate without updating React state: const mesh = useRef<THREE.Mesh>(null); useFrame((_, delta) => { if (!motionEnabled || !mesh.current) return; mesh.current.rotation.y += delta * rotationSpeed; }); This is a simplified example of the pattern used in the project. delta represents elapsed time since the previous frame. Using it makes rotation depend on time rather than the number of frames rendered. The same distinction applies to camera movement. In the engine exhibit, camera position and the orbit-control target ease toward their destinations using a time-dependent interpolation factor: useFrame((_, delta) => { if (!animating.current || !controls.current) return; const factor = motionEnabled ? 1 - Math.exp(-delta * 5.5) : 1; camera.position.lerp(goalPosition.current, factor); controls.current.target.lerp(goalTarget.current, factor); controls.current.update(); }); React decides where the camera should go. The render loop handles how it gets there. The engine camera also stops its automated movement when the visitor begins using the orbit controls. That prevents the camera from fighting the user. Making scrolling meaningful The Apollo exhibit, Thirteen Minutes, connects narrative progress with a scene representing the lunar descent. One small part of that system determines which narrative section contains the viewport’s centre: export function centeredBeatIndex( bounds: ReadonlyArray<{ top: number; bottom: number }>, viewportHeight: number, ) { const center = viewportHeight / 2; const index = bounds.findIndex( ({ top, bottom }) => top <= center && bottom > center, ); return index >= 0 ? index : null; } This function has no dependency on React, a camera, or the DOM. It receives measured bounds and returns an index. That makes it straightforward to test independently. The wider architecture separates scroll interpretation, scene state, camera behaviour, terrain, mission interface, and capability detection. That separation matters because “the animation feels wrong” can describe several different bugs: The wrong narrative section is active. The scene receives incorrect progress. The camera moves too abruptly. The scene has not loaded. A reduced-motion setting changes the presentation. Breaking the system into those responsibilities makes diagnosis much more precise. The engine exhibit: moving work into shaders The jet engine exhibit combines a loaded GLB model with procedural overlays. Its airflow visualisation uses particle attributes such as phase, angle, radius, and speed. A vertex shader uses those attributes and time uniforms to calculate movement. Conceptually: Particle attributes + time + engine settings ↓ Vertex shader ↓ Particle position and display colour React does not need to update the position of every particle every frame. The shader calculates those positions, while the application provides the parameters. The implementation also changes particle counts based on canvas width: <ParticleStream branch="bypass" count={desktop ? 4200 : 1800} props={props} /> <ParticleStream branch="core" count={desktop ? 2400 : 900} props={props} /> This is a concrete example of adapting rendering cost to the available display. The airflow remains an explanatory visualisation, however. Using a shader does not turn it into computational fluid dynamics or live aircraft telemetry. The representation still needs to communicate its limits. Asset handling is part of the engineering Working with 3D assets taught me that obtaining a model is only the beginning. The browser still has to download, decode, prepare, and render it. Materials, orientation, scale, and camera framing all affect the result. During the dinosaur exhibit’s development, there were missing skeletons, incorrect model presentations, and visible delays when changing specimens. Those were useful reminders that a model looking correct in isolation does not prove it works in the application. Another issue was a still image appearing before the interactive model. Even when a placeholder helps display something quickly, the visual handoff can feel like the interface loads twice. That needs to be evaluated as an interaction problem, alongside network and rendering measurements. Preloading also requires judgment. Loupe uses useGLTF.preload() in parts of its rendering code, but preloading every asset indiscriminately would shift the cost to bandwidth and memory. The question is which assets are likely to be needed next, and when loading them will help the visitor. Fallbacks belong in the architecture Atlas loads its browser-only canvas through a dynamic import: const InteractiveAtlasCanvas = dynamic( () => import("./AtlasCanvas"), { ssr: false }, ); Its stage also checks for WebGL availability and wraps the renderer in an error boundary. The Apollo scene considers reduced motion, reduced data, and whether the stage is near the viewport when deciding how to present the experience. These are separate concerns: A visitor may prefer less motion. A device may lack a usable graphics context. An asset may fail. A scene may not yet need to load. Treating all of those as one generic loading state makes failures harder to understand and recover from. A useful fallback preserves the exhibit’s meaning and available information. Scientific provenance becomes application data An educational application needs to explain what its assets represent. Loupe keeps asset ledgers alongside exhibit content. The planetary ledger records providers, source URLs, usage notes, and processing context. That makes provenance part of the content system rather than something reconstructed at launch. It also helps distinguish between an observed surface, a processed map, an illustrative interior, and a visual effect used to explain a relationship. For AI-assisted development, this creates a useful boundary: agents can help organise and implement sourced material, while claims and representations remain subject to review. Where Claude and Codex fit The strongest use of coding agents in this project has been working through connected engineering tasks. A rendering issue may involve a model loader, a material configuration, camera state, and a component’s loading boundary. Solving it can require changes across several files. Claude and Codex make it practical to work through that scope conversationally: investigate the behaviour, propose an approach, implement it, and inspect the result. I would not assign a fixed role such as “Claude does design” or “Codex does implementation.” The useful distinction is between the responsibilities in the workflow: Define the intended visitor experience. Give the agent repository context and constraints. Ask it to investigate or implement a bounded change. Inspect the actual result. Describe failures precisely. Verify the revised behaviour. A representative prompt is: Selecting a feature should bring it into view without breaking manual orbit controls. Inspect the current coordinate conversion and camera behaviour, explain the cause of the failure, and implement a fix with focused verification. That gives the agent an observable outcome and a constraint worth preserving. Tests help make iteration reviewable The repository includes Vitest coverage for content, state, calculations, and interface behaviour, along with Playwright suites for exhibit journeys. The test areas include planetary scale and orientation, scroll-state calculations, rendering decisions, content contracts, and exhibit interactions. Different checks answer different questions. A unit test can verify that coordinates produce the expected focus direction. A browser test can exercise selecting the feature. Visual inspection determines whether the resulting view actually communicates the feature clearly. Passing one does not replace the others. This is particularly important with generated code. Plausible implementation and plausible tests can still share the same mistaken assumption. What building Loupe changed for me The most significant change has been the scope of projects I am willing to attempt. An interactive museum crosses frontend architecture, graphics programming, asset preparation, scientific communication, and interaction design. Claude and Codex help me work across those areas and iterate on problems that would otherwise take much longer to explore. The responsibility for direction remains with me: choosing what matters, reviewing the evidence, inspecting the experience, and deciding whether a result is good enough. Loupe is one example of what that collaboration can produce. You can try it at https://loupe-museum.vercel.app/ If you explore it, I’d especially like developer feedback on model loading, camera controls, and how the interactions behave on your device.