How I build.

Engineering portfolio · Engineering Lead at DJUST

Frontend architecture, generative AI and real-time rendering. Instead of a list of keywords, a case study you can open and stress-test: this site.

Stack

Frontend

  • Nuxt / Vue
  • React 19
  • Next.js 16
  • TypeScript
  • Design systems
  • SSR / SSG

Generative AI

  • Agents & tool use
  • MCP
  • RAG
  • LLM integration
  • Realtime voice
  • AI coding agents

Backend

  • Node.js
  • Python
  • GraphQL
  • REST
  • PostgreSQL

3D & rendering

  • Three.js
  • React Three Fiber
  • GLSL
  • Post-processing

Cloud & quality

  • Google Cloud
  • Docker
  • CI/CD
  • Observabilité
  • Tests

This portfolio

A playable 3D dive, a voice agent that speaks on my behalf (and says it is an AI), and a static profile for people in a hurry. Designed by Sami and built with AI coding agents (Claude Code), from architecture to shaders: AI-assisted engineering, directed, reviewed and tested.

Rendering
Next.js 16 · App Router · static pages
Client
React 19 · R3F 9 · three 0.180
Voice
OpenAI Realtime · WebRTC
Agent
validated tools
Tests
node:test · transpiled TypeScript

Architecture

  1. ③ audio + data channel (WebRTC)
  2. tool call
  3. eventFromTool: validation
  4. dispatch
  5. 3D scene · game
  6. function_call_output

The browser talks to the model over WebRTC; the server only mints the session. Model actions come back as data, get validated, then applied to the scene.

Engineering decisions

The key never leaves the server

Problem
A real-time voice agent needs a browser-side session, but the API key must not be exposed there.
Decision
The browser only sends its SDP offer. The server pins model, tools, voice and limits, then relays the SDP answer.
Trade-off
One server round-trip at connect time, in exchange for a configuration nobody can tamper with.
voix · session (simplifié)
// The browser only sends its connection offer. Everything that matters
// (model, tools, voice, limits) is decided on the server, and the
// secret key never reaches the client.
const session = buildSession({ tools: allowlistedTools });
const answer = await relay(offer, session);   // key stays server-side
return answer;

A tool call is data, not a command

Problem
The model drives the scene: no invented actions, no double execution, no action from an interrupted response.
Decision
Strict schemas (exact keys, enumerated values), call_id de-duplication, cancelled responses ignored.
Trade-off
Less freedom for the model — by design.
agent · validation des outils
// A tool call is data, never authority: exact keys, enumerated values.
export function eventFromTool(name: unknown, args: unknown) {
  if (!args || typeof args !== 'object' || Array.isArray(args)) return null;
  const keys = Object.keys(args);
  if (name === 'show_project' && keys.length === 1 && keys[0] === 'project')
    return parseExperienceEvent({ type: 'showProject', project: args.project });
  // … one branch per tool; anything else is rejected (null)
}

// run once, and never for a response the visitor interrupted
if (!this.cancelledResponses.has(event.response_id) && !this.handledCalls.has(event.call_id)) {
  this.handledCalls.add(event.call_id);
  const result = action ? this.callbacks.scene(action) : null;
  this.send({ type: 'conversation.item.create', item: { type: 'function_call_output', … } });
}

A pure game core, outside React

Problem
Oxygen, quests and reveals change continuously; routing all of it through React state would re-render the page on every tick.
Decision
A pure, tested step() function and a tiny external store read by the HUD (useSyncExternalStore) and by the scene (getState in useFrame).
Trade-off
A tiny hand-rolled store instead of a library; zero added dependencies.
jeu · cœur pur
// Pure core: (state, telemetry, events) -> new state. Testable in Node.
export function step(state, sites, input, events = []) { … }

// Tiny external store: the HUD subscribes with useSyncExternalStore,
// the 3D scene reads getState() inside useFrame — no 60 fps re-renders.
export function createGameStore(sites, storage) {
  let state = initialGame();
  const listeners = new Set<() => void>();
  return {
    getState: () => state,
    subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
    tick(input) { commit(step(state, sites, input)); },
  };
}

Physics and shader cannot drift

Problem
The swell moves the water by ±0.6 m on the GPU, but the CPU decided “under water” at y = 0: fog and lens effects were wrong.
Decision
An analytic mirror of the swell in TypeScript, plus a test that reads the GLSL source and fails if they diverge.
Trade-off
Deliberate duplication, guarded by a test.
scène · houle + test
// "Under water" means under the actual wave, not under y = 0.
export function swellHeight(x, z, time, motion = 1, detail = 1) {
  const localY = -z;
  const a = (x * .93 + localY * .36) * .19 - time * .95;
  const b = (x * -.45 + localY * .89) * .39 - time * 1.35;
  return (Math.sin(a) * .42 + Math.sin(b) * .17 * detail) * motion;
}

// The test reads the GLSL source and fails if the shader drifts:
assert.match(shader, /swell\.z\+=\(sin\(a\)\*\.42\+sin\(b\)\*\.17\*swellDetail\)/);

Custom effects, cheaply

Problem
Interactive ripples on a 1.6 km ocean without heavier geometry.
Decision
Analytic wave packets injected into three.js Water shading, early-out far from the wave front, NaN-safe maths.
Trade-off
Ripples affect lighting, not geometry — invisible to the eye.
scène · ondes
// 12 wave packets injected into three.js Water: only normals are bent,
// so ripples stay sharp whatever the mesh resolution.
for (int i = 0; i < 12; i++) {
  vec4 r = ripples[i]; float age = rippleClock - r.z;
  if (r.w <= 0. || age < 0. || age > 7.) continue;
  vec2 delta = worldPosition.xz - r.xy; float d = length(delta) + .0001;
  float x = d - age * 2.35;
  if (abs(x) > 10. && d > 1.5) continue;          // early out far from the front
  float envelope = exp(-x * x / (.55 + age * 1.25)) * exp(-age * .62) * r.w / sqrt(1. + d * .35);
  float k = 6.2 - min(age, 4.) * .7;
  rippleSlope += delta / d * cos(x * k) * k * envelope * .11;
}

Performance adapts to the device

Problem
The same 3D world must run on an average phone and on a strong workstation.
Decision
Real-world fps sampling, automatic switch to a lighter tier, memoised R3F tree.
Trade-off
The low tier gives up post-processing and part of the vegetation.
scène · performance
// Quality adapts to the device: after 6 s below 30 fps,
// the scene switches to its low tier (no post-processing, fewer instances).
if (!low && !perf.reported && delta < .12 && clock.elapsedTime > 3) {
  perf.seconds += delta; perf.frames++;
  if (perf.seconds > 6) {
    perf.reported = true;
    if (perf.frames / perf.seconds < 30) onSlow();
  }
}

// The R3F tree is memoised: HUD updates never reconcile the 3D scene.
export default memo(function OceanScene(props) { … });

AI, the engineering way

  • Secrets and model configuration stay on the server.
  • Tools are narrow, typed and validated; model output is treated as untrusted input.
  • Explicit server-side budgets and limits; public voice is only opened once safeguards are in place.
  • Interruptions handled: a cut-off response can no longer act on the scene.
  • Honesty: the agent speaks on my behalf but says it is an AI, and only claims verified facts. Without an AI service, narration is announced as pre-recorded, never faked.

Quality and accessibility

  • Mobile first: touch joystick, 44 px targets, safe areas.
  • Reduced motion honoured everywhere, keyboard controls, focus trapped in dialogs.
  • Automated tests on the game core, voice, navigation and world geometry.
  • Public content locked by tests (no unwanted personal information).

Day to day, at DJUST

Since 2021: frontend architecture of a B2B SaaS platform, developer platform (components, TypeScript SDK, APIs) and AI-powered products. Engineering Lead since January 2026.

30-second profile →

Side projects