elastic Eric

This sketch creates a fully 3D, squishy character named Elastic Eric in WEBGL that responds to dragging and squishing. Toggle Puddle Mode to watch him melt into a gooey, fluid blob that pools in a glass bowl, complete with dynamic jaw animation, speech synthesis, and mobile-friendly touch controls.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make Eric bounce wildly — Lower the damping value to reduce friction, making every movement jiggle and bounce for way too long
  2. Make a giant Eric head — Increase the mesh radius so the head becomes huge and takes up most of the screen
  3. Speed up the melting transition — Increase the melt lerp rate so puddle mode transitions happen almost instantly
  4. Increase mesh smoothness — Raise cols and rows for finer geometry, making the head render smoother (slower performance)
  5. Make pupils gigantic — Increase the pupil diameter so Eric's eyes are huge and expressive
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch animates a cheerful 3D character called Elastic Eric whose entire body—from his squishy face to his bouncy limbs—responds to your dragging with realistic soft-body physics. The visual magic comes from three interconnected systems: a vertex mesh for the head that bends under tension, separate physics nodes for limbs that dangle and stretch, and a clever lerp-based state transition that morphs the entire character from solid to liquid when you hit Puddle Mode. The face even looks at your cursor and animates its jaw when Eric talks.

The code is organized around two physics classes—Node3D for the face mesh and LimbNode for arms and legs—plus a global meltFactor that smoothly interpolates between elastic and fluid states. You will learn how to build interactive 3D characters using verlet integration (position-based physics without rigid velocity), how to layer multiple rendering systems (3D meshes, limbs, lighting, and transparency), and how to coordinate state changes across an entire scene. Every dragged limb, melting animation, and jaw wiggle teaches you techniques used in professional interactive games and digital art.

⚙️ How It Works

  1. When the sketch loads, setup() initializes a 3D WEBGL canvas, builds a 35×35 vertex grid for the head mesh using spherical coordinates, and creates five LimbNode objects for the torso, two hands, and two feet. Two textures are pre-rendered: a static face with eyes, nose, and mouth that stays still, and a dynamic texture that updates the pupils every frame to track the cursor.
  2. Every frame, the draw() loop updates the meltFactor by lerping toward 0 (solid) or 1 (liquid) depending on the current physicsMode. This single number controls everything: it shifts target positions from rigidly defined to puddle-shaped, loosens the spring stiffness to make the body more squishy, and adds visual effects like the glass bowl fading in.
  3. When you drag any part of Eric—head, hands, torso, or feet—interactStart() detects which body part you grabbed using distance checks, and the drag is converted to a pullVec that influences the physics. For the head, dragging pulls the entire vertex mesh toward your cursor with a radial influence that falls off with distance; for limbs, the pullVec directly affects the LimbNode's target position.
  4. The head mesh updates its target positions by lerping between two preset configurations: rest (the original sphere) and puddleRest (a flattened, widened version). The physics engine then pulls each vertex toward its target using a spring force multiplied by stiffness, adds damping to prevent bouncing, and accumulates velocity to create momentum and follow-through.
  5. When Eric talks, a separate jaw animation loop (active when isTalking is true) offsets the lower-jaw vertices based on Perlin noise, creating organic bobbing. Simultaneously, the speech synthesis system uses the Web Speech API to vocalize random phrases with variable pitch and rate.
  6. Finally, the entire 3D scene is rendered with TRIANGLE_STRIP geometry for the head mesh (using vertex normals for shading), cylinders and spheres for the body, and a translucent glass bowl that fades in as meltFactor increases. Mouse rotation during play lets you explore Eric from all angles.

🎓 Concepts You'll Learn

WEBGL 3D renderingVerlet integration physicsMesh deformation and vertex manipulationSoft-body dynamicsState interpolation with lerpTouch and mouse interactionWeb Speech API integration

📝 Code Breakdown

Node3D class

Node3D represents a single vertex on Eric's head mesh. Each of the 35×35 vertices uses the same physics engine (verlet integration with spring forces and damping). The meltFactor smoothly transitions stiffness and damping to make the physics feel less bouncy and more liquid-like. This is the core technique behind soft-body physics: every vertex wants to return to its target but is held back by damping, creating jelly-like deformation.

🔬 These two lines swap values as Eric melts. What happens if you swap them—so stiffness gets stronger when melting? Try changing the first 0.15 to 0.04 and the 0.04 to 0.15. Will Eric get bouncier or stiffer as a puddle?

    let stiffness = lerp(0.15, 0.04, meltFactor); // Looser spring
    let damping = lerp(0.82, 0.94, meltFactor);   // More sloshy fluid friction
class Node3D {
  constructor(x, y, z, u, v, lat, lon) {
    this.rest = createVector(x, y, z);
    
    // Puddle mode dynamically flattens and widens the face!
    this.puddleRest = createVector(x * 2.5, 260 + y * 0.1, z * 2.5);
    
    this.target = createVector(x, y, z);
    this.pos = createVector(x, y, z);
    this.vel = createVector(0, 0, 0);
    this.u = u; this.v = v;
    this.lat = lat; this.lon = lon;
  }
  
  update() {
    // Dynamically change physics to fluid when melting
    let stiffness = lerp(0.15, 0.04, meltFactor); // Looser spring
    let damping = lerp(0.82, 0.94, meltFactor);   // More sloshy fluid friction
    
    let force = p5.Vector.sub(this.target, this.pos);
    force.mult(stiffness);
    this.vel.add(force);
    this.vel.mult(damping);
    this.pos.add(this.vel);
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

calculation 3D vertex initialization this.puddleRest = createVector(x * 2.5, 260 + y * 0.1, z * 2.5);

Pre-calculates where this vertex moves when melting (wider and flatter than the original sphere)

calculation Spring force computation let force = p5.Vector.sub(this.target, this.pos);

Calculates the vector from current position to target, which becomes the spring force

calculation Velocity damping this.vel.mult(damping);

Reduces velocity each frame, simulating air resistance and preventing infinite bouncing

this.rest = createVector(x, y, z);
Stores the original 3D position of this vertex on the sphere—the resting state before any deformation
this.puddleRest = createVector(x * 2.5, 260 + y * 0.1, z * 2.5);
Pre-calculates the flattened, widened puddle configuration: x and z expand by 2.5×, y gets clamped near y=260 to pool in the bowl
this.pos = createVector(x, y, z);
Tracks the actual current position of this vertex in 3D space, updated every frame by physics
this.vel = createVector(0, 0, 0);
Stores the velocity (rate of change) of this vertex, used by verlet integration to accumulate momentum
this.u = u; this.v = v;
UV coordinates for texture mapping—tell the GPU where on the face texture this vertex should display
let stiffness = lerp(0.15, 0.04, meltFactor);
When meltFactor = 0 (solid), stiffness = 0.15; when meltFactor = 1 (liquid), stiffness = 0.04. Lower stiffness = springier, slower recovery
let force = p5.Vector.sub(this.target, this.pos);
Spring physics: calculate the vector pointing from current position toward target, which pulls the vertex back
force.mult(stiffness);
Scale the spring force by stiffness—weaker stiffness produces less forceful pulls, allowing more wobble
this.vel.add(force);
Accumulate the spring force into velocity—this is verlet integration: forces become velocity changes
this.vel.mult(damping);
Apply damping: multiply velocity by a number less than 1 (e.g., 0.82) to gradually slow motion, simulating friction
this.pos.add(this.vel);
Update position: new position = old position + velocity. This is where the smooth motion happens each frame

LimbNode class

LimbNode is a simplified physics body for arms, legs, and torso. Unlike Node3D (which is a tiny vertex on a mesh), each LimbNode is a substantial body part that moves as a unit. The key innovation is the two-offset system: localOffset is where it sits in solid mode, and puddleOffset is where it melts to. By lerping between these two, Eric's entire skeleton smoothly transitions from rigid to liquid. The parentPos parameter is what makes limbs follow the torso around like a puppet.

🔬 The if/else here does something clever: the torso moves independently, but all limbs follow the torso around like marionette strings. What happens if you flip it—make ALL limbs absolute and the torso relative? Change the condition to: if (parentPos) { ... } and swap the contents. Will the torso hang from the hand?

    if (!parentPos) {
      // Torso has absolute target
      target = p5.Vector.lerp(this.rest, this.puddleOffset, meltFactor);
    } else {
      // Limbs target positions relative to the falling Torso
      let currentOffset = p5.Vector.lerp(this.localOffset, this.puddleOffset, meltFactor);
      target = p5.Vector.add(parentPos, currentOffset);
    }
class LimbNode {
  constructor(x, y, z, radius, localOffset, puddleOffset) {
    this.rest = createVector(x, y, z);
    this.pos = createVector(x, y, z);
    this.vel = createVector(0, 0, 0);
    this.radius = radius;
    this.localOffset = localOffset;     // Normal rigid position
    this.puddleOffset = puddleOffset;   // Melted position in the bowl
    this.dragAnchor = createVector(x, y, z);
  }
  
  update(pullVec, parentPos) {
    let target;
    
    if (!parentPos) {
      // Torso has absolute target
      target = p5.Vector.lerp(this.rest, this.puddleOffset, meltFactor);
    } else {
      // Limbs target positions relative to the falling Torso
      let currentOffset = p5.Vector.lerp(this.localOffset, this.puddleOffset, meltFactor);
      target = p5.Vector.add(parentPos, currentOffset);
    }

    if (pullVec) target.add(pullVec.copy().limit(350));

    let stiffness = lerp(0.15, 0.03, meltFactor);
    let damping = lerp(0.82, 0.95, meltFactor);

    let force = p5.Vector.sub(target, this.pos);
    force.mult(stiffness);
    this.vel.add(force);
    this.vel.mult(damping);
    this.pos.add(this.vel);
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Target position selection if (!parentPos) { // Torso has absolute target target = p5.Vector.lerp(this.rest, this.puddleOffset, meltFactor); } else { // Limbs target positions relative to the falling Torso let currentOffset = p5.Vector.lerp(this.localOffset, this.puddleOffset, meltFactor); target = p5.Vector.add(parentPos, currentOffset); }

The torso moves independently; arms and legs move relative to wherever the torso currently is (they follow it around)

calculation Drag force application if (pullVec) target.add(pullVec.copy().limit(350));

When dragging, adjust the target position by the pull vector, but cap it at 350 pixels max to prevent over-extension

this.localOffset = localOffset;
Stores the resting offset from the torso: e.g., left hand is at (-160, 140, 0) relative to torso center
this.puddleOffset = puddleOffset;
Stores where this limb moves when melting into the bowl: e.g., left hand puddles at (-180, 40, 0)
if (!parentPos) {
If there's no parent (only torso has no parent), calculate an absolute target; otherwise, target is relative to parent
target = p5.Vector.lerp(this.rest, this.puddleOffset, meltFactor);
Torso only: lerp between solid rest position and puddle position at the bottom of the bowl
let currentOffset = p5.Vector.lerp(this.localOffset, this.puddleOffset, meltFactor);
Limbs only: calculate where this limb should sit relative to torso by lerping between normal and puddle offsets
target = p5.Vector.add(parentPos, currentOffset);
Add the current offset to the parent's position to get the absolute target: this makes arms and legs follow the torso
if (pullVec) target.add(pullVec.copy().limit(350));
If being dragged, shift the target by the pull vector, but limit it to 350 pixels to prevent unrealistic stretching
let stiffness = lerp(0.15, 0.03, meltFactor);
Limbs get even less stiff when melting (0.03) than head (0.04), making them droopier and more wobbly
let damping = lerp(0.82, 0.95, meltFactor);
Puddle-mode limbs have extra damping (0.95) to move slowly and sluggishly, like gooey water

setup()

setup() is where all initialization happens: WEBGL canvas creation, texture pre-rendering, mesh generation, physics nodes, and Web Speech API integration. The spherical mesh is built using latitude/longitude coordinates, which wrap nicely around a sphere. Pre-rendering the static face into a separate texture is a key performance optimization: instead of redrawing all the facial features every frame, we only update the pupils and composite them on top. This cuts rendering cost by 80%.

🔬 These three lines are spherical coordinate math—they place each vertex on the surface of a perfect sphere. What happens if you change the formula to make the sphere into a stretched egg? Try multiplying the y coordinate by 1.5: let y = -radius * cos(lat) * 1.5;

      let x = radius * sin(lat) * sin(lon);
      let y = -radius * cos(lat); 
      let z = radius * sin(lat) * cos(lon);
function setup() {
  createCanvas(windowWidth, windowHeight, WEBGL);
  textureMode(NORMAL);
  
  buildUI();

  // 1. Initialize Textures
  faceTex = createGraphics(512, 512);
  staticFaceTex = createGraphics(512, 512);
  drawStaticFace(staticFaceTex); 
  
  torsoTex = createGraphics(512, 512);
  torsoTex.background('#E76F51');
  torsoTex.fill(255);
  torsoTex.textAlign(CENTER, CENTER);
  torsoTex.textStyle(BOLD);
  torsoTex.textSize(150);
  torsoTex.text("E", 128, 256);
  torsoTex.text("E", 384, 256);

  // 2. Initialize Face Nodes
  for (let i = 0; i <= cols; i++) {
    nodes[i] = [];
    let lat = map(i, 0, cols, 0, PI);
    for (let j = 0; j <= rows; j++) {
      let lon = map(j, 0, rows, -PI, PI);
      let x = radius * sin(lat) * sin(lon);
      let y = -radius * cos(lat); 
      let z = radius * sin(lat) * cos(lon);
      let u = map(lon, -PI, PI, 0, 1);
      let v = map(lat, 0, PI, 0, 1);
      nodes[i][j] = new Node3D(x, y, z, u, v, lat, lon);
    }
  }

  // 3. Initialize Body Physics Nodes (Normal Position, Melted Bowl Position)
  torsoNode = new LimbNode(0, 260, -20, 120, null, createVector(0, 680, 0));
  lHandNode = new LimbNode(-160, 400, -20, 45, createVector(-160, 140, 0), createVector(-180, 40, 0));
  rHandNode = new LimbNode(160, 400, -20, 45, createVector(160, 140, 0), createVector(180, 40, 0));
  lFootNode = new LimbNode(-70, 600, -20, 45, createVector(-70, 340, 0), createVector(-80, 40, 80));
  rFootNode = new LimbNode(70, 600, -20, 45, createVector(70, 340, 0), createVector(80, 40, 80));

  speechSynth = window.speechSynthesis;
  grabStartScreen = createVector();
}
Line-by-line explanation (15 lines)

🔧 Subcomponents:

calculation Canvas initialization createCanvas(windowWidth, windowHeight, WEBGL);

Creates a full-screen 3D canvas using WEBGL renderer for hardware-accelerated 3D graphics

calculation Texture pre-rendering staticFaceTex = createGraphics(512, 512); drawStaticFace(staticFaceTex);

Pre-renders the static face (eyes, nose, mouth) once to save performance—only pupils update each frame

for-loop Spherical mesh generation for (let i = 0; i <= cols; i++) { nodes[i] = []; let lat = map(i, 0, cols, 0, PI); for (let j = 0; j <= rows; j++) { let lon = map(j, 0, rows, -PI, PI); let x = radius * sin(lat) * sin(lon); let y = -radius * cos(lat); let z = radius * sin(lat) * cos(lon);

Creates a 35×35 grid of vertices positioned on a sphere using spherical coordinates (latitude/longitude)

calculation Body part initialization torsoNode = new LimbNode(0, 260, -20, 120, null, createVector(0, 680, 0));

Creates physics nodes for torso and limbs with both solid and puddle target positions

createCanvas(windowWidth, windowHeight, WEBGL);
Creates a full-window 3D canvas. WEBGL is p5's hardware-accelerated 3D renderer—necessary for smooth 3D animation
textureMode(NORMAL);
Sets texture coordinates to range 0-1 instead of 0-width/height, making texture mapping math simpler
buildUI();
Creates all HTML UI buttons (Play, Credits, Talk, Puddle Mode) and event listeners
faceTex = createGraphics(512, 512);
Creates an off-screen drawing canvas where the face texture will be updated every frame with new pupil positions
staticFaceTex = createGraphics(512, 512); drawStaticFace(staticFaceTex);
Pre-draws the static face (eyes outline, nose, mouth) once. This is never redrawn, only copied—huge performance boost
torsoTex.text("E", 128, 256); torsoTex.text("E", 384, 256);
Draws two large E's on the torso texture (Eric's initials), creating a fun shirt design
let lat = map(i, 0, cols, 0, PI);
Maps loop index i to latitude angle: 0 at north pole, PI at south pole
let lon = map(j, 0, rows, -PI, PI);
Maps loop index j to longitude angle: -PI on the left, +PI on the right (full circle)
let x = radius * sin(lat) * sin(lon);
Spherical coordinate formula: x component depends on both latitude and longitude
let y = -radius * cos(lat);
Y points from top (-radius) to bottom (+radius); cos(lat) creates the sphere's vertical curve
let z = radius * sin(lat) * cos(lon);
Z component also depends on both angles, completing the 3D spherical surface
let u = map(lon, -PI, PI, 0, 1); let v = map(lat, 0, PI, 0, 1);
UV texture coordinates: map 3D positions to 2D texture space so faces display correctly on the sphere
torsoNode = new LimbNode(0, 260, -20, 120, null, createVector(0, 680, 0));
Torso starts at (0, 260, -20) in solid mode, melts to (0, 680, 0) at the bottom of the puddle bowl
lHandNode = new LimbNode(-160, 400, -20, 45, createVector(-160, 140, 0), createVector(-180, 40, 0));
Left hand: solid offset is (-160, 140, 0) relative to torso, puddle offset is (-180, 40, 0)
speechSynth = window.speechSynthesis;
Grabs the browser's Web Speech API, enabling text-to-speech synthesis for Eric's voice

draw()

draw() is the heartbeat of Elastic Eric. It handles physics updates, collision detection, interaction, animation, and rendering—all sixty times per second. The meltFactor interpolation is key: by smoothly lerping a single number from 0 to 1, every system in the sketch (stiffness, limb positions, mesh deformation, bowl visibility) automatically transitions. The head dragging uses radial influence falloff to create believable soft-body deformation: vertices near your cursor move a lot, distant vertices barely move. The jaw animation uses Perlin noise instead of sine() for organic, natural-looking motion that doesn't feel mechanical.

🔬 This code converts screen coordinates (pixels) to world coordinates. What happens if you remove the getWorldMouse() conversion and just use raw mouseX, mouseY directly? Try: pullVec = createVector(mouseX - grabStartScreen.x, mouseY - grabStartScreen.y, 0); Will the dragging still work smoothly or will it feel broken?

  // --- Interaction Physics ---
  let pullVec = null;
  let wm, startWM;
  if (isDragging && gameState === 'PLAY') {
    cursor('grabbing');
    let currX = touches.length > 0 ? touches[0].x : mouseX;
    let currY = touches.length > 0 ? touches[0].y : mouseY;
    wm = getWorldMouse(currX, currY);
    startWM = getWorldMouse(grabStartScreen.x, grabStartScreen.y);
    pullVec = createVector(wm.x - startWM.x, wm.y - startWM.y, 0);
function draw() {
  background('#264653');
  updateFaceTexture(faceTex);

  ambientLight(130);
  directionalLight(255, 255, 255, 0.5, 0.5, -1);
  pointLight(255, 220, 200, -200, -200, 300);

  if (isTalking && !speechSynth.speaking) isTalking = false;

  // Melt state interpolation
  if (physicsMode === 'PUDDLE') {
    meltFactor = lerp(meltFactor, 1.0, 0.04);
  } else {
    meltFactor = lerp(meltFactor, 0.0, 0.06);
  }

  // --- Interaction Physics ---
  let pullVec = null;
  let wm, startWM;
  if (isDragging && gameState === 'PLAY') {
    cursor('grabbing');
    let currX = touches.length > 0 ? touches[0].x : mouseX;
    let currY = touches.length > 0 ? touches[0].y : mouseY;
    wm = getWorldMouse(currX, currY);
    startWM = getWorldMouse(grabStartScreen.x, grabStartScreen.y);
    pullVec = createVector(wm.x - startWM.x, wm.y - startWM.y, 0);
  } else {
    cursor(gameState === 'PLAY' ? 'grab' : 'default');
  }

  // Head Anchor naturally follows the Torso
  let headAnchor = p5.Vector.add(torsoNode.pos, createVector(0, -260, 0));

  // Update Head Matrix Targets
  if (isDragging && gameState === 'PLAY' && grabbedPart === 'head') {
    pullVec.limit(250);
    
    // Pulling the head drags the body slightly
    torsoNode.vel.add(p5.Vector.mult(pullVec, 0.02));

    for (let i = 0; i <= cols; i++) {
      for (let j = 0; j <= rows; j++) {
        let n = nodes[i][j];
        let currentRest = p5.Vector.lerp(n.rest, n.puddleRest, meltFactor);
        let worldRest = p5.Vector.add(headAnchor, currentRest);
        let d = dist(worldRest.x, worldRest.y, startWM.x, startWM.y);
        
        if (n.rest.z > -40) {
          let influence = pow(map(d, 0, 220, 1, 0, true), 1.8);
          n.target.x = worldRest.x + pullVec.x * influence;
          n.target.y = worldRest.y + pullVec.y * influence;
          n.target.z = worldRest.z + pullVec.z * influence * 0.4;
        } else {
          n.target = worldRest.copy();
        }
      }
    }
  } else {
    for (let i = 0; i <= cols; i++) {
      for (let j = 0; j <= rows; j++) {
        let n = nodes[i][j];
        let currentRest = p5.Vector.lerp(n.rest, n.puddleRest, meltFactor);
        n.target = p5.Vector.add(headAnchor, currentRest);
      }
    }
  }

  // Talking Jaw Animation
  if (isTalking) {
    let jawOffset = max(0, map(noise(frameCount * 0.5), 0, 1, -10, 60));
    for (let i = 0; i <= cols; i++) {
      for (let j = 0; j <= rows; j++) {
        let n = nodes[i][j];
        if (n.lat > PI * 0.55 && n.lat < PI * 0.9 && n.lon > -PI/2.5 && n.lon < PI/2.5) {
          let influence = map(dist(n.lon, n.lat, 0, PI * 0.7), 0, PI/2.5, 1, 0, true);
          n.target.y += jawOffset * influence;
          n.target.z -= jawOffset * influence * 0.3;
        }
      }
    }
  }

  // Engine Update
  torsoNode.update(grabbedPart === 'torso' ? pullVec : null, null);
  lHandNode.update(grabbedPart === 'lHand' ? pullVec : null, torsoNode.pos);
  rHandNode.update(grabbedPart === 'rHand' ? pullVec : null, torsoNode.pos);
  lFootNode.update(grabbedPart === 'lFoot' ? pullVec : null, torsoNode.pos);
  rFootNode.update(grabbedPart === 'rFoot' ? pullVec : null, torsoNode.pos);

  for (let i = 0; i <= cols; i++) {
    for (let j = 0; j <= rows; j++) nodes[i][j].update();
  }

  // --- Render 3D Scene ---
  push();
  translate(0, -100, 0); 
  scale(scaleFactor); 
  
  if (gameState !== 'PLAY') {
    rotateY(sin(frameCount * 0.02) * 0.3);
  } else {
    rotateY(map(mouseX, 0, width, -0.3, 0.3));
    rotateX(map(mouseY, 0, height, -0.2, 0.2));
  }

  drawBody(headAnchor);

  // Draw Head Mesh
  noStroke(); texture(faceTex); shininess(15); 
  for (let i = 0; i < cols; i++) {
    beginShape(TRIANGLE_STRIP);
    for (let j = 0; j <= rows; j++) {
      let n1 = nodes[i][j]; let n2 = nodes[i + 1][j];
      let norm1 = p5.Vector.sub(n1.pos, createVector(0,0,0)).normalize();
      normal(norm1.x, norm1.y, norm1.z);
      vertex(n1.pos.x, n1.pos.y, n1.pos.z, n1.u, n1.v);

      let norm2 = p5.Vector.sub(n2.pos, createVector(0,0,0)).normalize();
      normal(norm2.x, norm2.y, norm2.z);
      vertex(n2.pos.x, n2.pos.y, n2.pos.z, n2.u, n2.v);
    }
    endShape();
  }
  
  // Render the glass bowl if we are melting!
  if (meltFactor > 0.01) drawBowl();
  
  pop();
}
Line-by-line explanation (21 lines)

🔧 Subcomponents:

calculation 3D lighting setup ambientLight(130); directionalLight(255, 255, 255, 0.5, 0.5, -1); pointLight(255, 220, 200, -200, -200, 300);

Sets up three light sources: ambient (flat), directional (sun-like), and point light (warm spot) to create depth and dimension

conditional Melt factor interpolation if (physicsMode === 'PUDDLE') { meltFactor = lerp(meltFactor, 1.0, 0.04); } else { meltFactor = lerp(meltFactor, 0.0, 0.06); }

Smoothly transitions meltFactor from 0 to 1 (or back) at different speeds, creating a gradual melting/freezing effect

conditional Drag vector calculation if (isDragging && gameState === 'PLAY') { cursor('grabbing'); let currX = touches.length > 0 ? touches[0].x : mouseX; let currY = touches.length > 0 ? touches[0].y : mouseY; wm = getWorldMouse(currX, currY); startWM = getWorldMouse(grabStartScreen.x, grabStartScreen.y); pullVec = createVector(wm.x - startWM.x, wm.y - startWM.y, 0); }

Checks both mouse and touch input, converts screen coordinates to world coordinates, and calculates the drag delta vector

conditional Head-specific drag physics if (isDragging && gameState === 'PLAY' && grabbedPart === 'head') { pullVec.limit(250); torsoNode.vel.add(p5.Vector.mult(pullVec, 0.02)); for (let i = 0; i <= cols; i++) { for (let j = 0; j <= rows; j++) {

When dragging the head, pulls all front-facing mesh vertices toward the cursor with radial influence falloff

conditional Jaw motion during speech if (isTalking) { let jawOffset = max(0, map(noise(frameCount * 0.5), 0, 1, -10, 60)); for (let i = 0; i <= cols; i++) { for (let j = 0; j <= rows; j++) { let n = nodes[i][j]; if (n.lat > PI * 0.55 && n.lat < PI * 0.9 && n.lon > -PI/2.5 && n.lon < PI/2.5) {

Selects only the lower-face vertices (jaw region) and offsets them up/down based on Perlin noise, creating organic jaw bob

for-loop Head mesh rendering for (let i = 0; i < cols; i++) { beginShape(TRIANGLE_STRIP); for (let j = 0; j <= rows; j++) {

Renders the 35×35 mesh as pairs of triangle strips, computing vertex normals for per-vertex lighting

background('#264653');
Clears the canvas with a dark blue-gray every frame, removing the previous frame's pixels
updateFaceTexture(faceTex);
Redraw the face texture with the current pupil positions based on cursor/touch location
ambientLight(130);
Adds uniform ambient light (no shadows) at brightness 130, ensuring all parts of the 3D model are visible
directionalLight(255, 255, 255, 0.5, 0.5, -1);
Adds a distant directional light (like the sun) shining from direction (0.5, 0.5, -1), creating realistic shadows
pointLight(255, 220, 200, -200, -200, 300);
Adds a warm, yellow-orange point light at position (-200, -200, 300) for artistic rim-lighting effect
if (isTalking && !speechSynth.speaking) isTalking = false;
Sync the isTalking flag with the actual speech synthesis state—when speech stops, turn off jaw animation
if (physicsMode === 'PUDDLE') { meltFactor = lerp(meltFactor, 1.0, 0.04);
If in puddle mode, slowly lerp meltFactor toward 1.0 (fully melted) at a rate of 0.04 per frame—smooth 1-2 second transition
let currX = touches.length > 0 ? touches[0].x : mouseX;
On mobile, use touch position; on desktop, use mouse position. Ternary operator picks the right input
pullVec = createVector(wm.x - startWM.x, wm.y - startWM.y, 0);
Calculate the drag vector by subtracting start position from current position—this is how far you've dragged
let headAnchor = p5.Vector.add(torsoNode.pos, createVector(0, -260, 0));
Position the head 260 pixels above wherever the torso is—the neck's attachment point
if (n.rest.z > -40) {
Select only front-facing vertices (z > -40). Back-face vertices stay rigid so the back of the head doesn't deform
let influence = pow(map(d, 0, 220, 1, 0, true), 1.8);
Create a radial influence: vertices near the cursor (d=0) have influence=1, far away (d=220) have influence=0, then raise to power 1.8 for a sharper falloff
if (n.lat > PI * 0.55 && n.lat < PI * 0.9 && n.lon > -PI/2.5 && n.lon < PI/2.5) {
Latitude and longitude bounds select the lower-front face (chin/jaw region)—only these vertices animate when talking
let jawOffset = max(0, map(noise(frameCount * 0.5), 0, 1, -10, 60));
Use Perlin noise to generate smooth, organic jaw movement: noise() ranges -10 to 60 (opens upward when talking), max(0, ...) prevents negative movement
torsoNode.update(grabbedPart === 'torso' ? pullVec : null, null);
Update torso physics, passing the pull vector only if torso is being dragged, otherwise null
lHandNode.update(grabbedPart === 'lHand' ? pullVec : null, torsoNode.pos);
Update left hand physics, passing torso position as parent so hand follows torso around
for (let i = 0; i < cols; i++) { beginShape(TRIANGLE_STRIP);
Render each column of the mesh as a triangle strip—TRIANGLE_STRIP is efficient and automatic wraps the vertices into triangles
let norm1 = p5.Vector.sub(n1.pos, createVector(0,0,0)).normalize();
Compute vertex normal: point from origin to vertex and normalize it. For a sphere, normals naturally point radially outward
normal(norm1.x, norm1.y, norm1.z);
Tell the GPU the direction this vertex faces, so it can calculate shading correctly based on incoming light
vertex(n1.pos.x, n1.pos.y, n1.pos.z, n1.u, n1.v);
Send vertex position AND UV texture coordinates to the GPU so the face image maps correctly onto the mesh
if (meltFactor > 0.01) drawBowl();
Only render the glass bowl when melting has started (meltFactor > 0), to avoid unnecessary geometry early on

updateFaceTexture()

updateFaceTexture() demonstrates a crucial performance optimization: pre-render static content once (the face features), then update only the dynamic parts (pupils) each frame. This cuts rendering cost enormously. The eye-tracking effect uses a simple but effective trick: map the mouse position (which ranges over the entire canvas) to a small pupil offset (only ±12 pixels), making the pupils always visible but responsive to cursor movement. This creates the illusion of the character looking at you.

🔬 This code maps mouse position to pupil offset, making eyes follow the cursor. What happens if you make the ranges BIGGER? Change -400, 400 to -1000, 1000. Will the pupils look at your cursor from farther away?

  let mx = constrain(mouseX - width / 2, -400, 400);
  let my = constrain(mouseY - height / 2, -400, 400); 
  let pupilOffX = map(mx, -400, 400, -12, 12);
  let pupilOffY = map(my, -400, 400, -12, 12);
function updateFaceTexture(pg) {
  pg.image(staticFaceTex, 0, 0);
  let mx = constrain(mouseX - width / 2, -400, 400);
  let my = constrain(mouseY - height / 2, -400, 400); 
  let pupilOffX = map(mx, -400, 400, -12, 12);
  let pupilOffY = map(my, -400, 400, -12, 12);

  pg.noStroke(); pg.fill(30);
  pg.ellipse(256 - 60 + pupilOffX, 256 - 40 + pupilOffY, 20, 20);
  pg.ellipse(256 + 60 + pupilOffX, 256 - 40 + pupilOffY, 20, 20);
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Mouse position clamping let mx = constrain(mouseX - width / 2, -400, 400);

Constrains mouse movement to stay within -400 to 400 pixel range, preventing pupils from looking too far

calculation Pupil offset calculation let pupilOffX = map(mx, -400, 400, -12, 12);

Maps mouse position (-400 to 400) to pupil offset (-12 to 12 pixels) for eye-tracking effect

calculation Pupil rendering pg.ellipse(256 - 60 + pupilOffX, 256 - 40 + pupilOffY, 20, 20);

Draw left pupil at position adjusted by mouse offset—creates illusion the eyes are looking at cursor

pg.image(staticFaceTex, 0, 0);
Draw the pre-rendered static face (eyes outline, nose, mouth) onto the texture canvas—this is 99% of the face, only drawn once during setup
let mx = constrain(mouseX - width / 2, -400, 400);
Convert mouseX to a range centered on 0 (width/2 is canvas center), then clamp between -400 and 400 pixels to limit pupil movement
let my = constrain(mouseY - height / 2, -400, 400);
Same for vertical: center on canvas, then clamp to prevent pupils from rolling too far
let pupilOffX = map(mx, -400, 400, -12, 12);
Convert mouse range (-400 to 400) to pupil offset range (-12 to 12): when mouse is at -400, pupil shifts left by 12; at +400, shifts right by 12
pg.ellipse(256 - 60 + pupilOffX, 256 - 40 + pupilOffY, 20, 20);
Draw left pupil: base position (256 - 60, 256 - 40) plus the mouse-based offset—the +offset makes pupils track cursor

drawBody()

drawBody() renders Eric's skeleton as a collection of spheres (joints), cylinders (bones), and an ellipsoid (torso). The breathing animation uses a sine wave for smooth, continuous motion. The key technique is non-uniform scaling of the torso: when melting, it flattens vertically and expands horizontally, creating the illusion of gravity pulling a soft body downward. Joint anchors (shoulders, hips) are calculated relative to the torso position, so all limbs automatically follow when you drag the body around.

🔬 This draws a complete arm: shoulder joint (sphere), bone (cylinder), and hand (sphere). What if you change the bone thickness from 22 to 5? Will the arm look like a toothpick?

  // Left Arm
  fill('#E76F51');
  push(); translate(lShoulder.x, lShoulder.y, lShoulder.z); sphere(35); pop();
  drawBone(lShoulder, lHandNode.pos, 22);
  fill('#FFD1B3');
  push(); translate(lHandNode.pos.x, lHandNode.pos.y, lHandNode.pos.z); sphere(25); pop();
function drawBody(headAnchor) {
  let breath = (physicsMode === 'ELASTIC') ? sin(frameCount * 0.05) * 5 : 0;
  let tPos = torsoNode.pos;

  // Neck
  let neckTop = p5.Vector.add(headAnchor, createVector(0, 140, -20));
  let torsoTop = createVector(tPos.x, tPos.y - 100 + breath, tPos.z);
  fill('#FFD1B3');
  drawBone(neckTop, torsoTop, 25);

  // Torso
  push();
  translate(tPos.x, tPos.y + breath, tPos.z);
  
  // When melting, the torso visually flattens like a pancake!
  scale(1.0, lerp(1.0, 0.2, meltFactor), lerp(1.0, 1.5, meltFactor));
  
  textureMode(NORMAL); texture(torsoTex);
  rotateY(HALF_PI);
  ellipsoid(120, 140, 80);
  pop();

  // Joint Anchors (attached to moving torso)
  let lShoulder = createVector(tPos.x - 100, tPos.y - 60 + breath, tPos.z);
  let rShoulder = createVector(tPos.x + 100, tPos.y - 60 + breath, tPos.z);
  let lHip = createVector(tPos.x - 50, tPos.y + 110 + breath, tPos.z);
  let rHip = createVector(tPos.x + 50, tPos.y + 110 + breath, tPos.z);

  // Left Arm
  fill('#E76F51');
  push(); translate(lShoulder.x, lShoulder.y, lShoulder.z); sphere(35); pop();
  drawBone(lShoulder, lHandNode.pos, 22);
  fill('#FFD1B3');
  push(); translate(lHandNode.pos.x, lHandNode.pos.y, lHandNode.pos.z); sphere(25); pop();

  // Right Arm
  fill('#E76F51');
  push(); translate(rShoulder.x, rShoulder.y, rShoulder.z); sphere(35); pop();
  drawBone(rShoulder, rHandNode.pos, 22);
  fill('#FFD1B3');
  push(); translate(rHandNode.pos.x, rHandNode.pos.y, rHandNode.pos.z); sphere(25); pop();

  // Left Leg
  fill('#2A9D8F'); 
  push(); translate(lHip.x, lHip.y, lHip.z); sphere(35); pop();
  drawBone(lHip, lFootNode.pos, 28);
  fill('#E9C46A'); 
  push(); translate(lFootNode.pos.x, lFootNode.pos.y + 15, lFootNode.pos.z + 15); ellipsoid(22, 18, 35); pop();

  // Right Leg
  fill('#2A9D8F');
  push(); translate(rHip.x, rHip.y, rHip.z); sphere(35); pop();
  drawBone(rHip, rFootNode.pos, 28);
  fill('#E9C46A');
  push(); translate(rFootNode.pos.x, rFootNode.pos.y + 15, rFootNode.pos.z + 15); ellipsoid(22, 18, 35); pop();
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

calculation Breathing animation let breath = (physicsMode === 'ELASTIC') ? sin(frameCount * 0.05) * 5 : 0;

Adds a subtle up-down bobbing when solid (using sine wave), stops when melting

calculation Torso melting deformation scale(1.0, lerp(1.0, 0.2, meltFactor), lerp(1.0, 1.5, meltFactor));

When melting, the torso flattens vertically (Y scale 1.0 → 0.2) and expands horizontally (Z scale 1.0 → 1.5)

calculation Joint position calculation let lShoulder = createVector(tPos.x - 100, tPos.y - 60 + breath, tPos.z);

Calculate where arms and legs attach to the torso, offset by fixed amounts so they follow torso around

let breath = (physicsMode === 'ELASTIC') ? sin(frameCount * 0.05) * 5 : 0;
When solid, add a gentle breathing motion (sine wave) that oscillates ±5 pixels; when melting, breath = 0 (no breathing as a puddle)
let tPos = torsoNode.pos;
Cache the torso's current position to use multiple times without recalculating
let neckTop = p5.Vector.add(headAnchor, createVector(0, 140, -20));
Neck top position is the head anchor (bottom of head) plus an offset downward, positioning where neck connects to head
let torsoTop = createVector(tPos.x, tPos.y - 100 + breath, tPos.z);
Torso top is the torso position minus 100 pixels (up), plus breathing motion, so neck connects upper torso to lower head
drawBone(neckTop, torsoTop, 25);
Render a cylinder bone connecting head and torso with thickness 25
push(); translate(tPos.x, tPos.y + breath, tPos.z);
Push matrix state, then move origin to torso position (with breathing motion) before drawing the ellipsoid
scale(1.0, lerp(1.0, 0.2, meltFactor), lerp(1.0, 1.5, meltFactor));
Non-uniform scaling: when melting, compress Y to 20% (flatten) and expand Z to 150% (widen) to make a pancake shape
ellipsoid(120, 140, 80);
Draw the torso as an ellipsoid (squashed sphere) with dimensions 120×140×80 in X, Y, Z
let lShoulder = createVector(tPos.x - 100, tPos.y - 60 + breath, tPos.z);
Left shoulder is 100 pixels left of torso, 60 pixels up, with breathing motion—all limbs use similar offsets
push(); translate(lShoulder.x, lShoulder.y, lShoulder.z); sphere(35); pop();
Draw a 35-pixel radius sphere at the shoulder joint—this is the shoulder ball. push/pop isolates the transform
drawBone(lShoulder, lHandNode.pos, 22);
Render a bone (cylinder) from shoulder to hand with thickness 22, forming the arm
push(); translate(lHandNode.pos.x, lHandNode.pos.y, lHandNode.pos.z); sphere(25); pop();
Draw a 25-pixel sphere at the hand position—the hand itself. Color set by previous fill() call

drawBone()

drawBone() is a key helper function that renders cylinders between any two 3D points. The tricky part is orientation: p5's cylinder() points upward by default, but bones need to point from v1 to v2 at any angle. The solution uses vector math: dot product finds the rotation angle, cross product finds the rotation axis, and rotate(angle, axis) applies the rotation. This is a common pattern in 3D graphics for aligning geometry.

🔬 This code calculates a 3D rotation from one vector direction to another using dot and cross products. What if you change the default axis to (0, 0, 1) (pointing forward instead of up)? Will the bones rotate differently?

  let dir = p5.Vector.sub(v2, v1).normalize();
  let axis = createVector(0, 1, 0); 
  let angle = acos(axis.dot(dir));
  let axisRot = axis.cross(dir);
function drawBone(v1, v2, thickness) {
  let d = p5.Vector.dist(v1, v2);
  if (d < 1) return; // Prevent matrix collapse
  let mid = p5.Vector.add(v1, v2).mult(0.5);
  
  push();
  translate(mid.x, mid.y, mid.z);
  
  let dir = p5.Vector.sub(v2, v1).normalize();
  let axis = createVector(0, 1, 0); 
  let angle = acos(axis.dot(dir));
  let axisRot = axis.cross(dir);
  
  if (axisRot.magSq() > 0.001) {
    axisRot.normalize();
    rotate(angle, axisRot);
  } else if (axis.dot(dir) < -0.99) {
    rotateX(PI);
  }
  
  cylinder(thickness, d);
  pop();
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

calculation Bone length calculation let d = p5.Vector.dist(v1, v2); if (d < 1) return;

Calculate distance between two points; if too short (< 1 pixel), abort to prevent degenerate cylinder

calculation Cylinder orientation let dir = p5.Vector.sub(v2, v1).normalize(); let axis = createVector(0, 1, 0); let angle = acos(axis.dot(dir)); let axisRot = axis.cross(dir);

Calculate rotation needed to align a default upright cylinder with the direction from v1 to v2

let d = p5.Vector.dist(v1, v2);
Measure the distance between start and end points—this will be the cylinder's height (length)
if (d < 1) return;
Safety check: if bones are too short (less than 1 pixel), skip rendering to prevent degenerate geometry
let mid = p5.Vector.add(v1, v2).mult(0.5);
Find the midpoint between v1 and v2—this is where the cylinder will be centered
translate(mid.x, mid.y, mid.z);
Move the origin to the midpoint, so when we draw the cylinder, it spans from v1 to v2 correctly
let dir = p5.Vector.sub(v2, v1).normalize();
Calculate the direction vector from v1 to v2, then normalize it to unit length
let axis = createVector(0, 1, 0);
The default cylinder points upward (positive Y). This is the 'up' direction we're comparing against
let angle = acos(axis.dot(dir));
Use dot product to find the angle between default 'up' and desired direction: acos(dot product) = angle
let axisRot = axis.cross(dir);
Cross product gives the axis perpendicular to both, which is the rotation axis
if (axisRot.magSq() > 0.001) { axisRot.normalize(); rotate(angle, axisRot);
If the cross product is non-zero (bone isn't perfectly vertical), normalize it and rotate by the calculated angle
} else if (axis.dot(dir) < -0.99) { rotateX(PI);
Edge case: if the bone points straight down, the cross product is zero. Use a 180° rotation around X axis instead
cylinder(thickness, d);
Draw the cylinder with the given radius (thickness) and height (d), already oriented correctly

drawBowl()

drawBowl() creates a semi-transparent glass bowl using the same spherical mesh technique as the head. The key trick is that bowl transparency is controlled by meltFactor: it fades in as Eric melts, creating the visual effect of a container appearing when needed. The rotateX(PI) flip ensures the bowl opens upward instead of downward. In a full game, this could become a physics container that actually constrains the puddle's movement.

🔬 This double loop creates the bowl mesh with 0.2 radian steps (about 11 degrees). What if you change 0.2 to 0.05 for finer mesh resolution? Will the bowl look smoother?

  for (let lat = 0; lat < HALF_PI; lat += 0.2) {
    beginShape(TRIANGLE_STRIP);
    for (let lon = 0; lon <= TWO_PI + 0.1; lon += 0.2) {
      let r = 320;
      let lat2 = lat + 0.2;
function drawBowl() {
  push();
  translate(0, 680, 0);
  fill(150, 220, 255, 80 * meltFactor); // Semi-transparent glass
  stroke(200, 240, 255, 120 * meltFactor);
  strokeWeight(2);
  rotateX(PI); // Open upwards
  
  // Custom Hemisphere Mesh
  for (let lat = 0; lat < HALF_PI; lat += 0.2) {
    beginShape(TRIANGLE_STRIP);
    for (let lon = 0; lon <= TWO_PI + 0.1; lon += 0.2) {
      let r = 320;
      let lat2 = lat + 0.2;
      vertex(r * sin(lat) * cos(lon), r * cos(lat), r * sin(lat) * sin(lon));
      vertex(r * sin(lat2) * cos(lon), r * cos(lat2), r * sin(lat2) * sin(lon));
    }
    endShape();
  }
  pop();
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

calculation Bowl placement translate(0, 680, 0);

Position the bowl 680 pixels down, where the puddle collects

calculation Transparency interpolation fill(150, 220, 255, 80 * meltFactor);

Bowl only becomes visible when meltFactor > 0, fading in as melting progresses

for-loop Hemisphere mesh generation for (let lat = 0; lat < HALF_PI; lat += 0.2) { beginShape(TRIANGLE_STRIP); for (let lon = 0; lon <= TWO_PI + 0.1; lon += 0.2) {

Creates hemisphere geometry using spherical coordinates, similar to head mesh but inverted

push();
Save the current matrix state so the bowl's translation and rotation don't affect other geometry
translate(0, 680, 0);
Position the bowl's center 680 pixels down on the Y axis—below where Eric melts
fill(150, 220, 255, 80 * meltFactor);
Bowl color is light blue (150, 220, 255) with alpha = 80 * meltFactor. When meltFactor=0, alpha=0 (invisible); when meltFactor=1, alpha=80 (visible but transparent)
stroke(200, 240, 255, 120 * meltFactor);
Bowl outline is lighter blue with alpha = 120 * meltFactor, creating a glassy appearance
rotateX(PI);
Rotate 180° around X axis to flip the hemisphere upside-down so it opens upward like a bowl (not downward)
for (let lat = 0; lat < HALF_PI; lat += 0.2) {
Loop over latitude: 0 at top, HALF_PI/2 at bottom, stepping by 0.2 radians (~11°) for mesh resolution
for (let lon = 0; lon <= TWO_PI + 0.1; lon += 0.2) {
Loop over longitude (full circle): 0 to TWO_PI, stepping by 0.2 radians, creating a circular cross-section at each latitude
let lat2 = lat + 0.2;
Calculate the next latitude band, used to form the top and bottom of each triangle strip
vertex(r * sin(lat) * cos(lon), r * cos(lat), r * sin(lat) * sin(lon));
First vertex of this edge, using spherical coordinates: x, y, z
vertex(r * sin(lat2) * cos(lon), r * cos(lat2), r * sin(lat2) * sin(lon));
Second vertex, one latitude band further down—TRIANGLE_STRIP connects these automatically
pop();
Restore the previous matrix state so transforms are undone

speak()

speak() integrates the Web Speech API, a browser feature that converts text to speech with configurable pitch and rate. By syncing the isTalking flag with speech start/end events, we trigger the jaw animation in draw() exactly when Eric is talking. The pitch of 1.3 makes him sound cute and high-energy; the rate of 1.0 keeps normal-speed speech. This is a simple but effective way to add personality and feedback to interactive 3D characters.

🔬 These three lines sync the jaw animation to speech. What if you set isTalking = true in onstart but never set it back to false? The jaw will animate forever. Try commenting out the onend line: // utterance.onend = () => { isTalking = false; };

  utterance.onstart = () => { isTalking = true; };
  utterance.onend = () => { isTalking = false; };
  utterance.onerror = () => { isTalking = false; };
function speak(textStr) {
  if (speechSynth.speaking) return; 
  let utterance = new SpeechSynthesisUtterance(textStr);
  utterance.pitch = 1.3; utterance.rate = 1.0;
  utterance.onstart = () => { isTalking = true; };
  utterance.onend = () => { isTalking = false; };
  utterance.onerror = () => { isTalking = false; };
  speechSynth.speak(utterance);
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Speech queue guard if (speechSynth.speaking) return;

Prevents queuing multiple speeches—only one at a time

calculation Voice properties utterance.pitch = 1.3; utterance.rate = 1.0;

Sets pitch (higher = squeakier) and rate (1.0 = normal speed)

calculation Speech event listeners utterance.onstart = () => { isTalking = true; }; utterance.onend = () => { isTalking = false; }; utterance.onerror = () => { isTalking = false; };

Hook into speech events to sync the jaw animation with actual speech

if (speechSynth.speaking) return;
Check if speech is already playing. If so, skip (prevents overlapping speech)
let utterance = new SpeechSynthesisUtterance(textStr);
Create a new utterance object from the Web Speech API, wrapping the text string
utterance.pitch = 1.3;
Set pitch to 1.3 (30% higher than default), making Eric sound squeaky and cute
utterance.rate = 1.0;
Set speech rate to 1.0 (normal speed). Higher values speed up; lower values slow down
utterance.onstart = () => { isTalking = true; };
When speech begins, set isTalking = true, which triggers the jaw animation in draw()
utterance.onend = () => { isTalking = false; };
When speech ends normally, set isTalking = false to stop jaw animation
utterance.onerror = () => { isTalking = false; };
If speech fails (e.g., browser doesn't support it), also set isTalking = false to reset
speechSynth.speak(utterance);
Trigger the browser's text-to-speech engine to speak the utterance

interactStart()

interactStart() is the input handler called when the user clicks or taps. It performs multi-body collision detection by checking distance from the cursor to each body part; whichever part is closest (within a radius) gets grabbed. The dragAnchor system locks starting positions so drag calculations work correctly even if the body is already moving. The random voice lines add personality and make the interaction feel responsive and alive.

🔬 This chain of if/else checks collision with each body part. What if you increase the hit radius from radius + 30 to radius + 100? Will it be easier or harder to grab limbs?

  if (dist(wm.x, wm.y, lHandNode.pos.x, lHandNode.pos.y) < lHandNode.radius + 30) {
    grabbedPart = 'lHand';
  } else if (dist(wm.x, wm.y, rHandNode.pos.x, rHandNode.pos.y) < rHandNode.radius + 30) {
    grabbedPart = 'rHand';
function interactStart(sx, sy) {
  if (gameState !== 'PLAY') return;

  grabStartScreen = createVector(sx, sy);
  let wm = getWorldMouse(sx, sy);

  // Lock initial positions for proper dragging math
  torsoNode.dragAnchor = torsoNode.pos.copy();
  lHandNode.dragAnchor = lHandNode.pos.copy();
  rHandNode.dragAnchor = rHandNode.pos.copy();
  lFootNode.dragAnchor = lFootNode.pos.copy();
  rFootNode.dragAnchor = rFootNode.pos.copy();

  let headAnchorY = torsoNode.pos.y - 260;

  // Collision detection checking
  if (dist(wm.x, wm.y, lHandNode.pos.x, lHandNode.pos.y) < lHandNode.radius + 30) {
    grabbedPart = 'lHand';
  } else if (dist(wm.x, wm.y, rHandNode.pos.x, rHandNode.pos.y) < rHandNode.radius + 30) {
    grabbedPart = 'rHand';
  } else if (dist(wm.x, wm.y, lFootNode.pos.x, lFootNode.pos.y) < lFootNode.radius + 30) {
    grabbedPart = 'lFoot';
  } else if (dist(wm.x, wm.y, rFootNode.pos.x, rFootNode.pos.y) < rFootNode.radius + 30) {
    grabbedPart = 'rFoot';
  } else if (dist(wm.x, wm.y, torsoNode.pos.x, torsoNode.pos.y) < torsoNode.radius + 30) {
    grabbedPart = 'torso';
  } else if (dist(wm.x, wm.y, 0, headAnchorY) < radius + 40) { 
    grabbedPart = 'head';
  } else {
    return;
  }

  isDragging = true;
  
  if (random() < 0.4 && physicsMode === 'ELASTIC') {
    speak(random(["Careful with the face!", "Whoa, stretchy!", "Ow, let go!"]));
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

conditional Game state check if (gameState !== 'PLAY') return;

Ignore interaction if not in play mode (e.g., on menu screens)

conditional Multi-body collision detection if (dist(wm.x, wm.y, lHandNode.pos.x, lHandNode.pos.y) < lHandNode.radius + 30) { grabbedPart = 'lHand';

Tests distance from cursor to each body part; if under threshold, that part is grabbed

conditional Interaction feedback if (random() < 0.4 && physicsMode === 'ELASTIC') { speak(random(["Careful with the face!", "Whoa, stretchy!", "Ow, let go!"]));

40% chance to play a voice line when grabbing, making Eric respond to being poked

if (gameState !== 'PLAY') return;
Only allow interaction during gameplay—menu screens and credits don't respond to drags
grabStartScreen = createVector(sx, sy);
Record screen coordinates where the drag started, used later to calculate drag distance
let wm = getWorldMouse(sx, sy);
Convert screen coordinates to world coordinates (accounts for canvas scaling and offset)
torsoNode.dragAnchor = torsoNode.pos.copy();
Lock the starting position of the torso so drag calculations are relative to where it was, not where it moves to
let headAnchorY = torsoNode.pos.y - 260;
Pre-calculate where the head's bottom center is (260 pixels above torso), for collision testing
if (dist(wm.x, wm.y, lHandNode.pos.x, lHandNode.pos.y) < lHandNode.radius + 30) {
Check if click is within lHandNode.radius (25) + 30 = 55 pixels of the left hand—a collision hit
grabbedPart = 'lHand';
If hit, record which part is grabbed so physics and animation know what to follow
} else if (dist(wm.x, wm.y, 0, headAnchorY) < radius + 40) {
Check head: distance from click to head center (0, headAnchorY) must be within radius + 40 pixels
isDragging = true;
Set the global dragging flag to true, triggering physics updates in draw()
if (random() < 0.4 && physicsMode === 'ELASTIC') {
40% of the time (when solid), randomly play a voice line to give Eric personality
speak(random(["Careful with the face!", "Whoa, stretchy!", "Ow, let go!"]));
Pick one of three random phrases and speak it—player feedback and character expression

📦 Key Variables

gameState string

Tracks the current screen: 'HOME' (menu), 'CREDITS', or 'PLAY' (active game). Controls what interactions are allowed

let gameState = 'HOME';
physicsMode string

Tracks whether Eric is solid ('ELASTIC') or melted ('PUDDLE'). Changes physics behavior and visuals

let physicsMode = 'ELASTIC';
meltFactor number

A 0-1 blend factor that controls the transition from solid to liquid. Interpolates stiffness, damping, mesh deformation, and bowl visibility

let meltFactor = 0;
nodes array

A 2D array of Node3D objects forming the 35×35 mesh of Eric's head. Each vertex responds to physics and dragging

let nodes = [];
torsoNode object

LimbNode for the torso (body center). Parent to all limbs; controls breathing and melting

let torsoNode;
lHandNode, rHandNode, lFootNode, rFootNode object

LimbNode objects for arms and legs. Each moves relative to the torso and responds to dragging

let lHandNode;
faceTex, staticFaceTex, torsoTex object

Off-screen graphics buffers used as textures. faceTex updates every frame with pupil positions; staticFaceTex is pre-rendered; torsoTex shows the 'E' suit design

let faceTex = createGraphics(512, 512);
isDragging boolean

Global flag indicating whether the user is currently dragging a body part. Controls cursor style and physics

let isDragging = false;
grabbedPart string

Tracks which body part is being dragged: 'head', 'torso', 'lHand', 'rHand', 'lFoot', or 'rFoot'

let grabbedPart = null;
grabStartScreen object

Records the screen position where the drag started, used to calculate drag delta vector

let grabStartScreen = createVector();
isTalking boolean

Flag synced to Web Speech API events. When true, the jaw animates. Set by speak() event listeners

let isTalking = false;
speechSynth object

Reference to the browser's Web Speech API SpeechSynthesis object, used to vocalize text

let speechSynth = window.speechSynthesis;
cols, rows number

Constants defining the mesh resolution: 35 columns × 35 rows = 1225 vertices forming the head

const cols = 35, rows = 35;
radius number

Constant radius of the spherical head mesh in pixels (150). Used for collision detection and mesh generation

const radius = 150;
scaleFactor number

Global 3D scene scale (0.6). Scales the entire Eric model down so he fits the screen without being too large

const scaleFactor = 0.6;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

PERFORMANCE updateFaceTexture()

Redrawing the entire face texture every frame is expensive; the static background is copied 60 times per second unnecessarily

💡 Cache the static face as a pre-rendered texture and only update the pupils on a smaller dynamic layer, compositing them at render time. Or use a shader to calculate pupil positions without CPU-side texture updates

BUG interactStart() collision detection

Collision checks use 2D distance (dist with only X, Y) but the scene is 3D with perspective. A hand occluded behind the head can still be clicked

💡 Project 3D positions to screen space before distance checking, or use raycasting to find which object the click ray intersects first

STYLE Node3D and LimbNode physics

Spring stiffness and damping values are hard-coded in the update() methods. To tweak physics, users must modify source code

💡 Move physics constants to module-level variables or a config object, allowing easy tuning without code changes. Add a debug UI to adjust these values in real-time

FEATURE draw() rotation

In home screen, Eric rotates via sin(frameCount * 0.02). In play mode, rotation is mouse-driven. There's no smooth transition between these states

💡 Lerp the rotation angles so switching modes feels natural instead of sudden. Store target rotation and current rotation separately

PERFORMANCE Head mesh rendering

Every frame, all 1225 vertices are updated and re-rendered even if they're not visible or moving. No culling or LOD system

💡 Implement frustum culling to skip vertices outside the camera view. For heavy melting animations, use lower mesh resolution (fewer cols/rows)

BUG drawBone() rotation

Edge case when bone points straight up (dir ≈ [0, 1, 0]): cross product is zero, defaulting to rotateX(PI). This can cause visual flipping

💡 Use a more robust 3D rotation method like quaternions, or detect the axis.dot(dir) ≈ 1.0 case and skip rotation entirely

🔄 Code Flow

Code flow showing node3d, limbnode, setup, draw, updatefacetexture, drawbody, drawbone, drawbowl, speak, interactstart

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> setup-canvas[Canvas Initialization] setup-canvas --> setup-textures[Texture Pre-rendering] setup-textures --> setup-mesh-loop[Spherical Mesh Generation] setup-mesh-loop --> setup-limb-nodes[Body Part Initialization] setup --> draw[draw loop] draw --> draw-lighting[3D Lighting Setup] draw --> draw-melt-lerp[Melt Factor Interpolation] draw --> draw-drag-input[Drag Vector Calculation] draw --> draw-head-dragging[Head-specific Drag Physics] draw --> draw-jaw-animation[Jaw Motion During Speech] draw --> draw-mesh-rendering[Head Mesh Rendering] draw --> updatefacetexture[updateFaceTexture] updatefacetexture --> updateface-constrain[Mouse Position Clamping] updateface-constrain --> updateface-mapping[Pupil Offset Calculation] updateface-mapping --> updateface-drawing[Pupil Rendering] draw --> drawbody[drawBody] drawbody --> drawbody-breathing[Breathing Animation] drawbody --> drawbody-torso-scale[Torso Melting Deformation] drawbody --> drawbody-joint-anchors[Joint Position Calculation] drawbody --> drawbone[drawBone] drawbone --> drawbone-length[Bone Length Calculation] drawbone --> drawbone-orientation[Cylinder Orientation Calculation] draw --> drawbowl[drawBowl] drawbowl --> drawbowl-positioning[Bowl Placement] drawbowl --> drawbowl-color[Transparency Interpolation] drawbowl --> drawbowl-mesh[Hemisphere Mesh Generation] draw --> speak[speak] speak --> speak-guard[Speech Queue Guard] speak --> speak-properties[Voice Properties] speak --> speak-listeners[Speech Event Listeners] draw --> interactstart[interactStart] interactstart --> interactstart-guard[Game State Check] interactstart --> interactstart-collision[Multi-body Collision Detection] interactstart-collision --> interactstart-feedback[Interaction Feedback] click setup href "#fn-setup" click draw href "#fn-draw" click setup-canvas href "#sub-setup-canvas" click setup-textures href "#sub-setup-textures" click setup-mesh-loop href "#sub-setup-mesh-loop" click setup-limb-nodes href "#sub-setup-limb-nodes" click draw-lighting href "#sub-draw-lighting" click draw-melt-lerp href "#sub-draw-melt-lerp" click draw-drag-input href "#sub-draw-drag-input" click draw-head-dragging href "#sub-draw-head-dragging" click draw-jaw-animation href "#sub-draw-jaw-animation" click draw-mesh-rendering href "#sub-draw-mesh-rendering" click updatefacetexture href "#fn-updatefacetexture" click updateface-constrain href "#sub-updateface-constrain" click updateface-mapping href "#sub-updateface-mapping" click updateface-drawing href "#sub-updateface-drawing" click drawbody href "#fn-drawbody" click drawbody-breathing href "#sub-drawbody-breathing" click drawbody-torso-scale href "#sub-drawbody-torso-scale" click drawbody-joint-anchors href "#sub-drawbody-joint-anchors" click drawbone href "#fn-drawbone" click drawbone-length href "#sub-drawbone-length" click drawbone-orientation href "#sub-drawbone-orientation" click drawbowl href "#fn-drawbowl" click drawbowl-positioning href "#sub-drawbowl-positioning" click drawbowl-color href "#sub-drawbowl-color" click drawbowl-mesh href "#sub-drawbowl-mesh" click speak href "#fn-speak" click speak-guard href "#sub-speak-guard" click speak-properties href "#sub-speak-properties" click speak-listeners href "#sub-speak-listeners" click interactstart href "#fn-interactstart" click interactstart-guard href "#sub-interactstart-guard" click interactstart-collision href "#sub-interactstart-collision" click interactstart-feedback href "#sub-interactstart-feedback"

❓ Frequently Asked Questions

What unique visual experience does the 'Elastic Eric' sketch offer?

The 'Elastic Eric' sketch creates a playful 3D character with a bouncy face and limbs that jiggle as users drag them around, showcasing a transformation into a gooey blob in Puddle Mode.

How can users interact with the 'Elastic Eric' creative coding sketch?

Users can drag the character's limbs and face to manipulate them, and they can activate Puddle Mode to see Eric melt into a liquid form.

What creative coding concepts are demonstrated in the 'Elastic Eric' sketch?

The sketch demonstrates soft-body physics and dynamic morphing between solid and liquid states using techniques like interpolation and vector manipulation.

Preview

elastic Eric - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of elastic Eric - Code flow showing node3d, limbnode, setup, draw, updatefacetexture, drawbody, drawbone, drawbowl, speak, interactstart
Code Flow Diagram