## What changed - the-matrix/js/agents.js fully rewritten with face expression system ## Face geometry - Replaced flat dark-blue eye spheres with white sclera (MeshStandardMaterial, emissive 0x777777@0.10, roughness 0.55) + dark pupils (MeshBasicMaterial 0x07070f) as child meshes of sclera - Eyes are now children of the head mesh (not the group) so they naturally follow head.rotation.z tilts driven by the existing animation loop - Mouth added as a canvas Sprite (128x32, always faces camera) parented to the group so it bobs with Timmy's body; drawn via quadraticCurveTo bezier arc ## Emotion → face parameter mapping (FACE_TARGETS table) - idle (contemplative): lidScale=0.44, smileAmount=0.08 — half-lid, neutral - active (curious): lidScale=0.92, smileAmount=0.38 — wide eyes, smile - thinking (focused): lidScale=0.30, smileAmount=-0.06 — squint, flat mouth - working (attentive): lidScale=0.22, smileAmount=0.18 — very squint, slight grin ## Per-frame lerp (updateAgents) - faceParams lerped toward faceTarget at rate 0.055/frame (smooth, no snap) - eyeL.scale.y / eyeR.scale.y driven by faceParams.lidScale (squash = squint) - Mouth canvas redrawn only when |smileDelta| > 0.016 or speakingChanged (avoids unnecessary texture uploads every frame) ## Lip-sync while speaking - While speechTimer > 0: smileTarget = 0.28 + sin(t*6.283)*0.22 (~1 Hz) - _drawMouth() renders two-lip "open mouth" shape when speaking=true - Returns to mood expression when speechTimer expires ## Validation - Vite build: clean (14 modules, 529 kB bundle, no errors) - testkit: 27/27 PASS (no regressions) - No out-of-scope changes (backend untouched)