elastic Eric but worst and better idk

This sketch creates a highly interactive 3D character named Elastic Eric that stretches and deforms under drag interactions, with a special 'puddle mode' that melts the character into a liquid-like state inside a glass bowl. The character features a procedurally-generated spherical head mesh, articulated limbs with physics, facial animations including blinking and talking, and responsive haptic feedback on mobile devices.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make Eric giggle when you click him — Every time you grab Eric, he'll speak one of several responses. Try reducing the probability from 0.4 (40%) to 0.9 (90%) so he talks almost every time.
  2. Speed up the puddle melting — When you click PUDDLE MODE, Eric melts toward liquid state. Increase the lerp speed from 0.04 to 0.15 to make melting happen 3× faster.
  3. Make Eric wobble more when relaxed — Limbs gently sway when Eric is idle. Increase the sway amplitude from * 8 to * 20 so limbs bounce higher.
  4. Darken the background — The sky is currently dark teal. Change it to pure black or deep navy for a moodier scene.
  5. Make the head mesh spikier when dragged — When you pull the head, the pull distance is limited to 250 pixels. Increase to 400 to allow more extreme stretching.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates Elastic Eric, a delightfully stretchy 3D character you can drag, squeeze, and deform in real-time. What makes it visually engaging is the combination of soft-body physics (each part wobbles and springs back), articulated limbs that follow the torso, facial expressions including blinking eyes and an animated talking mouth, and a stunning 'puddle mode' that flattens and liquefies the entire character into a glass bowl. The main techniques powering this are WebGL 3D rendering with custom mesh generation, spring-based verlet physics for soft deformation, and dynamic texture updates for facial animation.

The code is organized into two physics classes (Node3D for the head mesh and LimbNode for limbs), a complete animation pipeline in draw() that updates physics and renders the 3D scene, and helper functions for interaction, speech synthesis, and UI management. By reading it you will learn how to build a deformable 3D mesh, combine multiple physics systems in one character, layer textures and facial animations, and handle complex touch/mouse interaction in WebGL mode.

⚙️ How It Works

  1. When the sketch loads, setup() creates a WEBGL canvas, initializes a spherical head mesh by creating a grid of Node3D particles, spawns limb nodes for the torso and four extremities, and pre-draws a static face texture to reuse each frame for performance.
  2. Every frame, draw() clears the background and updates the face texture with animated pupils that follow the mouse, blinking eyelids, and a talking mouth if speech is active.
  3. The physics engine updates all head mesh nodes toward their target positions using spring forces and damping, creating elastic stretching behavior; limbs update similarly but also tether relative to the torso position, maintaining the character's skeleton even while deforming.
  4. When the player drags a part (head, hand, foot, or torso), the clicked region of the mesh receives a pull vector that influences nearby nodes based on distance, creating a radial deformation effect that falls off smoothly.
  5. The 'puddle mode' smoothly interpolates meltFactor from 0 to 1, which lerps each node's target position between its 'rest' (normal) pose and its 'puddleRest' (flattened, widened bowl) pose, transforming the character's shape and physics constants to feel fluid and sloshy.
  6. Finally, the 3D scene is rendered with lighting, the head mesh is textured with the animated face, limbs are drawn as cylinders and spheres, and a semi-transparent glass bowl and water surface appear when melting begins.

🎓 Concepts You'll Learn

WebGL 3D rendering with WEBGL modeCustom mesh generation and vertex manipulationVerlet integration and spring physicsSoft-body deformation from drag interactionState machines (game states: HOME, CREDITS, PLAY)Dynamic texture generation and updatingFacial animation (blinking, talking, pupils)Touch and mouse interaction handlingVector math and distance-based influence

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. This is where you initialize all your data structures, textures, and game state. For this sketch, setup() builds the entire 3D character: the spherical head mesh using mathematical sphere parameterization, the limbs with their dual rest/puddle positions, and the UI. Understanding how the Node3D grid is constructed is key to understanding how deformation works later.

🔬 These three lines use sine and cosine to convert lat/lon into 3D coordinates (sphere math). What happens if you add a multiplier like `let z = radius * sin(lat) * cos(lon) * 2;` to stretch the head in the Z direction?

      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();

  // First blink sometime soon after play starts
  nextBlinkFrame = int(random(40, 160));
}
Line-by-line explanation (17 lines)

🔧 Subcomponents:

calculation Canvas and texture setup createCanvas(windowWidth, windowHeight, WEBGL);

Creates a full-screen WEBGL canvas (3D rendering mode) and configures texture coordinates to use normalized 0–1 ranges

for-loop Spherical head mesh generation for (let i = 0; i <= cols; i++) { for (let j = 0; j <= rows; j++) { ... } }

Creates a 2D grid of Node3D particles arranged in latitude/longitude coordinates to form a sphere, storing both UV texture coordinates and spherical math data

calculation Limb node creation torsoNode = new LimbNode(0, 260, -20, 120, null, createVector(0, 680, 0));

Spawns the torso and four limbs with both normal resting positions and puddle-melted positions stored in each node

createCanvas(windowWidth, windowHeight, WEBGL);
Creates a full-window WEBGL (3D) canvas; WEBGL mode unlocks 3D transforms, lighting, and mesh rendering
textureMode(NORMAL);
Sets texture coordinates to use normalized 0–1 range (instead of pixel coordinates), making textures map consistently regardless of size
buildUI();
Calls the UI builder to create HOME screen, CREDITS screen, and in-game buttons (PLAY, PUDDLE MODE, etc.)
faceTex = createGraphics(512, 512);
Creates an off-screen graphics buffer (512×512 pixels) that will hold the animated face texture updated every frame
staticFaceTex = createGraphics(512, 512);
Creates a second graphics buffer for the static (unchanging) parts of the face; drawn once and reused to save performance
drawStaticFace(staticFaceTex);
Pre-draws the static face features (eyes, cheeks, nose, eyebrows, mouth outline) once to a texture, avoiding redrawing them every frame
let lat = map(i, 0, cols, 0, PI);
Maps column index i to latitude values (0 to π), representing vertical position on a sphere from top pole to bottom pole
let lon = map(j, 0, rows, -PI, PI);
Maps row index j to longitude values (−π to π), representing horizontal wrap-around position on a sphere from left to right
let x = radius * sin(lat) * sin(lon);
Converts spherical latitude/longitude to Cartesian X coordinate using standard sphere parameterization; multiplied by radius (150) to scale the mesh
let y = -radius * cos(lat);
Converts latitude to Cartesian Y (vertical); negated so the north pole points upward; this is the head's vertical axis
let z = radius * sin(lat) * cos(lon);
Converts spherical latitude/longitude to Cartesian Z coordinate; forms the depth dimension of the sphere
let u = map(lon, -PI, PI, 0, 1);
Maps longitude to horizontal texture coordinate (0 to 1); tells the texture how to wrap left/right around the sphere
let v = map(lat, 0, PI, 0, 1);
Maps latitude to vertical texture coordinate (0 to 1); tells the texture how to wrap top/bottom around the sphere
nodes[i][j] = new Node3D(x, y, z, u, v, lat, lon);
Creates a mesh node at this grid position, storing position, texture coordinates, and spherical math needed for later deformations
torsoNode = new LimbNode(0, 260, -20, 120, null, createVector(0, 680, 0));
Creates the torso node at (0, 260, -20) with radius 100; puddleOffset is (0, 680, 0) so it falls to the bowl bottom when melting
lHandNode = new LimbNode(-160, 400, -20, 45, createVector(-160, 140, 0), createVector(-180, 40, 0));
Creates left hand node; localOffset (−160, 140, 0) is its normal position relative to torso; puddleOffset (−180, 40, 0) is where it lands in the puddle
nextBlinkFrame = int(random(40, 160));
Schedules the first blink to occur randomly between frame 40 and 160, so Eric's eyes don't stay frozen after loading

buildUI()

buildUI() creates the entire DOM hierarchy for menus and in-game UI. It demonstrates p5.js DOM manipulation functions like createDiv(), createButton(), select(), and event handlers like mousePressed(). Understanding this function teaches you how to layer interactive HTML UI on top of a p5.js canvas sketch.

function buildUI() {
  // HOME
  let homeContainer = createDiv('');
  homeContainer.id('home-screen');
  homeContainer.addClass('ui-container');
  createElement('h1', 'ELASTIC ERIC').parent(homeContainer);
  
  let playBtn = createButton('PLAY GAME').parent(homeContainer).addClass('btn');
  playBtn.mousePressed(() => {
    select('#home-screen').hide();
    select('#talk-btn').style('display', 'block');
    select('#puddle-btn').style('display', 'block');
    resetBtn.style('display', 'block');
    hintLabel.style('display', 'block');
    hintLabel.style('opacity', '1');
    gameState = 'PLAY';
    lastInteractionFrame = frameCount;
    speak("Elastic Eric is ready for action!");
  });
  
  let credBtn = createButton('CREDITS').parent(homeContainer).addClass('btn');
  credBtn.mousePressed(() => {
    select('#home-screen').hide();
    select('#credits-screen').style('display', 'flex');
  });

  // CREDITS
  let credContainer = createDiv('').id('credits-screen').addClass('ui-container').hide();
  createElement('h2', 'Made by Corbun').parent(credContainer);
  let backBtn = createButton('BACK').parent(credContainer).addClass('btn');
  backBtn.mousePressed(() => {
    select('#credits-screen').hide();
    select('#home-screen').style('display', 'flex');
  });

  // IN-GAME BUTTONS
  let tBtn = createButton("🗣️ Make Him Talk!").id('talk-btn').addClass('btn').hide();
  tBtn.mousePressed(() => {
    makeTalk();
    lastInteractionFrame = frameCount;
    doVibrate(15);
  });
  
  let pBtn = createButton("💧 PUDDLE MODE").id('puddle-btn').addClass('btn').hide();
  pBtn.mousePressed(() => {
    togglePhysics();
    lastInteractionFrame = frameCount;
  });

  // NEW: Reset pose button
  resetBtn = createButton("RESET POSE").id('reset-btn').addClass('btn').hide();
  resetBtn.mousePressed(() => {
    resetPose();
    doVibrate([10, 20, 10]);
  });

  // NEW: Hint label at the bottom
  hintLabel = createDiv('Drag my head, hands or feet!<br/>Try <b>PUDDLE MODE</b> to melt me.');
  hintLabel.id('hint-label');
  hintLabel.addClass('hint-label');
  hintLabel.hide();
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

calculation HOME screen setup let homeContainer = createDiv(''); homeContainer.id('home-screen');

Creates the initial menu screen with PLAY GAME and CREDITS buttons

conditional Play button click handler playBtn.mousePressed(() => { select('#home-screen').hide(); ... gameState = 'PLAY'; ... });

When clicked, hides the home screen, shows in-game UI, changes state to PLAY, and speaks a greeting

calculation In-game button creation let tBtn = createButton(...).id('talk-btn')...; let pBtn = createButton(...).id('puddle-btn')...; resetBtn = createButton(...);

Creates the TALK, PUDDLE MODE, and RESET POSE buttons that appear during gameplay

let homeContainer = createDiv('');
Creates an empty HTML div that will hold all HOME screen elements
homeContainer.id('home-screen');
Assigns the ID 'home-screen' so CSS styling and selectors can target this container
createElement('h1', 'ELASTIC ERIC').parent(homeContainer);
Creates an <h1> heading with text 'ELASTIC ERIC' and nests it inside homeContainer
playBtn.mousePressed(() => { ... gameState = 'PLAY'; ... });
Attaches a click handler that transitions from HOME state to PLAY state by hiding the home screen and showing game buttons
select('#home-screen').hide();
Uses p5.js select() to find the #home-screen element and calls hide() to set display: none
speak("Elastic Eric is ready for action!");
Uses the Web Speech API to speak this greeting text aloud when the player clicks PLAY
let credContainer = createDiv('').id('credits-screen').addClass('ui-container').hide();
Creates the CREDITS screen as a hidden div; it is shown when CREDITS button is clicked and hidden when BACK is clicked
let tBtn = createButton("🗣️ Make Him Talk!").id('talk-btn').addClass('btn').hide();
Creates the TALK button with an emoji, assigns ID and CSS class for styling, and hides it initially (shown only during PLAY)
tBtn.mousePressed(() => { makeTalk(); lastInteractionFrame = frameCount; doVibrate(15); });
When clicked, calls makeTalk() to play a random phrase, records the interaction time for hint fading, and vibrates the device
let pBtn = createButton("💧 PUDDLE MODE").id('puddle-btn').addClass('btn').hide();
Creates the PUDDLE MODE toggle button; clicking it calls togglePhysics() to switch between elastic and liquid states
resetBtn = createButton("RESET POSE").id('reset-btn').addClass('btn').hide();
Creates the RESET POSE button that returns Eric to his default cool posture when clicked
hintLabel = createDiv('Drag my head, hands or feet!<br/>Try <b>PUDDLE MODE</b> to melt me.');
Creates an instructional hint div that tells new players how to interact; fades out after ~10 seconds of inactivity

draw()

draw() is the heart of any p5.js sketch—it runs 60 times per second and controls all animation. This function shows how to combine 3D rendering (WebGL transforms, lighting, meshes) with physics updates and interaction. Key learning: the order matters—first update physics, then calculate positions, then render. This sketch also shows advanced techniques like dynamic texture updates, conditional mesh deformation, and multi-part character animation.

🔬 These lines control how Eric rotates. In PLAY mode, mouse X controls Y-rotation and mouse Y controls X-rotation. What happens if you swap them with `rotateY(map(mouseY, 0, height, -0.3, 0.3));` and `rotateX(map(mouseX, 0, width, -0.2, 0.2));`? The controls will feel inverted!

  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));
  }

🔬 These lines control how fast Eric melts or solidifies. The 0.04 and 0.06 are lerp speeds (higher = faster). What if you change 0.04 to 0.15 to make Eric melt almost instantly when you click PUDDLE MODE?

  if (physicsMode === 'PUDDLE') {
    meltFactor = lerp(meltFactor, 1.0, 0.04);
  } else {
    meltFactor = lerp(meltFactor, 0.0, 0.06);
  }
function draw() {
  background('#264653');

  // Update face texture each frame
  updateFaceTexture(faceTex);

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

  if (isTalking && speechSynth && !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 (drives lower face mesh)
  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));
  }

  // Simple floor/ground so Eric isn't floating in space
  push();
  translate(0, 520, -200);
  rotateX(HALF_PI);
  noStroke();
  ambientMaterial(30, 70, 80);
  plane(2200, 1500, 1, 1);
  pop();

  drawBody(headAnchor);

  // Draw Head Mesh
  noStroke();
  texture(faceTex);
  shininess(25 + 25 * (1.0 - meltFactor)); 
  
  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];

      // Better normals: from head center instead of global origin
      let norm1 = p5.Vector.sub(n1.pos, headAnchor).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, headAnchor).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();

  // Fade hint label over time as player interacts
  updateHintOpacity();
}
Line-by-line explanation (42 lines)

🔧 Subcomponents:

calculation Face texture update updateFaceTexture(faceTex);

Refreshes the face texture each frame to animate pupils, blinking, and mouth movements

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

Smoothly transitions meltFactor between 0 (solid) and 1 (puddle) based on current physics mode

calculation Drag vector from interaction if (isDragging && gameState === 'PLAY') { ... pullVec = createVector(wm.x - startWM.x, wm.y - startWM.y, 0); }

Calculates the pull vector (distance from grab start to current cursor) that influences mesh deformation

conditional Head mesh target positions if (isDragging && gameState === 'PLAY' && grabbedPart === 'head') { ... for (...) n.target = ... } else { for (...) n.target = ... }

Sets each mesh node's target position either toward the drag pull (if grabbed) or back to rest position (if not)

conditional Talking jaw deformation if (isTalking) { ... for (...) if (n.lat > PI * 0.55 && ...) n.target.y += jawOffset * influence; }

Modulates the lower face mesh nodes' Y and Z positions based on noise to create a talking jaw motion

calculation Physics engine tick torsoNode.update(...); lHandNode.update(...); for (...) nodes[i][j].update();

Updates all nodes' physics: applies spring forces, damping, and moves positions based on velocities

calculation 3D scene rendering push(); translate(0, -100, 0); scale(scaleFactor); rotateY(...); rotateX(...); ... drawBody(...); ... texture(faceTex); for (...) { ... vertex(...); } pop();

Renders the complete 3D scene: applies transforms, draws the body and limbs, renders the head mesh with textures

background('#264653');
Clears the canvas with a dark teal color each frame, erasing the previous frame's rendering
updateFaceTexture(faceTex);
Calls the function that redraws the animated face texture, including pupils, blink state, and talking mouth
ambientLight(130);
Adds uniform ambient lighting at brightness level 130 so all surfaces are lit even without direct light
directionalLight(255, 255, 255, 0.5, 0.5, -1);
Adds a white directional light shining from angle (0.5, 0.5, -1), creating depth-giving shadows and highlights
pointLight(255, 220, 200, -200, -200, 300);
Adds a warm point light (like a lamp) at position (-200, -200, 300) creating warm highlights and soft shadows
if (isTalking && speechSynth && !speechSynth.speaking) { isTalking = false; }
Checks if the Web Speech API has finished speaking; if so, sets isTalking to false to stop mouth animation
if (physicsMode === 'PUDDLE') { meltFactor = lerp(meltFactor, 1.0, 0.04); }
If puddle mode is active, smoothly interpolates meltFactor toward 1.0 at speed 0.04 per frame (creates slow melting)
} else { meltFactor = lerp(meltFactor, 0.0, 0.06); }
If elastic mode is active, smoothly interpolates meltFactor back toward 0.0 at speed 0.06 per frame (solidifies faster than melting)
let currX = touches.length > 0 ? touches[0].x : mouseX;
On mobile, use the first touch position; on desktop, use mouse position—enables both touch and mouse interaction
wm = getWorldMouse(currX, currY);
Converts screen coordinates to world coordinates in the 3D space using the getWorldMouse() helper function
pullVec = createVector(wm.x - startWM.x, wm.y - startWM.y, 0);
Calculates the pull vector as the difference between current and starting drag positions; this controls how far Eric stretches
cursor(gameState === 'PLAY' ? 'grab' : 'default');
Changes the cursor to 'grab' during play (inviting interaction) or 'default' in menus
let headAnchor = p5.Vector.add(torsoNode.pos, createVector(0, -260, 0));
Calculates the head's position by placing it 260 pixels above the torso, so it moves when the torso is dragged
if (isDragging && gameState === 'PLAY' && grabbedPart === 'head') {
Enters this block only when the head is actively being dragged by the player
pullVec.limit(250);
Clamps the pull vector's magnitude to 250 pixels maximum, preventing extreme stretching
torsoNode.vel.add(p5.Vector.mult(pullVec, 0.02));
Adds a small portion of the pull vector to the torso's velocity, so pulling the head slightly drags the body along
let d = dist(worldRest.x, worldRest.y, startWM.x, startWM.y);
Calculates the distance from this mesh node's rest position to the grab point, used to determine deformation influence
let influence = pow(map(d, 0, 220, 1, 0, true), 1.8);
Maps distance 0–220 to influence 1–0 (closer = stronger), then raises to power 1.8 for smooth falloff—nodes far from grab barely move
n.target.x = worldRest.x + pullVec.x * influence;
Sets this node's target position by adding the pull vector scaled by influence; this creates the stretching effect
if (n.rest.z > -40) {
Only deforms the front-facing nodes (Z > −40); back-of-head nodes stay still, preventing weird stretching through the back
if (isTalking) {
Enters this block when Eric is speaking via text-to-speech
let jawOffset = max(0, map(noise(frameCount * 0.5), 0, 1, -10, 60));
Uses Perlin noise to generate smooth random jaw movements between -10 and 60 pixels; frequency is 0.5 so motion is slow
if (n.lat > PI * 0.55 && n.lat < PI * 0.9 && n.lon > -PI/2.5 && n.lon < PI/2.5) {
Only affects lower-face mesh nodes (latitude 55–90% down, longitude within front 2/5), so only the jaw moves
torsoNode.update(grabbedPart === 'torso' ? pullVec : null, null);
Updates torso physics: if grabbed, passes the pull vector; if not, passes null so it relaxes back to rest
lHandNode.update(grabbedPart === 'lHand' ? pullVec : null, torsoNode.pos);
Updates left hand: if grabbed, passes pull vector; always passes torso position so hand stays tethered to torso
for (let i = 0; i <= cols; i++) { for (let j = 0; j <= rows; j++) nodes[i][j].update(); }
Updates all head mesh nodes' physics in one loop—each node applies spring forces and moves based on velocity
push();
Saves the current transformation matrix (position, rotation, scale) so transforms inside don't affect code after pop()
translate(0, -100, 0);
Shifts the 3D scene upward by 100 pixels so Eric is positioned nicely on screen
scale(scaleFactor);
Scales the entire 3D scene by scaleFactor (0.6), shrinking Eric and all 3D objects proportionally
if (gameState !== 'PLAY') { rotateY(sin(frameCount * 0.02) * 0.3); }
In menus, auto-rotates Eric slowly (Y rotation oscillates by sin() at frequency 0.02) for a demo effect
} else { rotateY(map(mouseX, 0, width, -0.3, 0.3)); rotateX(map(mouseY, 0, height, -0.2, 0.2)); }
During play, lets the player rotate Eric by moving the mouse: left/right mouse motion rotates Y, up/down rotates X
ambientMaterial(30, 70, 80);
Sets the floor's material to a dark blue-green that reflects ambient light (no shininess)
plane(2200, 1500, 1, 1);
Draws a flat rectangular plane (floor) 2200×1500 pixels; the 1, 1 creates minimal subdivision for performance
texture(faceTex);
Tells p5.js to apply the faceTex graphics (the animated face) to all shapes drawn after this line
shininess(25 + 25 * (1.0 - meltFactor));
Sets material shininess: solid Eric shines (shininess 50), but liquid puddle Eric is matte (shininess 25)
for (let i = 0; i < cols; i++) { beginShape(TRIANGLE_STRIP); ... vertex(...); ... endShape(); }
Renders the head mesh as vertical strips of triangles: for each column, creates triangles connecting top and bottom rows
let norm1 = p5.Vector.sub(n1.pos, headAnchor).normalize();
Calculates a normal vector (perpendicular to surface) pointing outward from the head center, needed for correct lighting
normal(norm1.x, norm1.y, norm1.z);
Sets the normal for the next vertex; this tells the lighting engine which way the surface faces
vertex(n1.pos.x, n1.pos.y, n1.pos.z, n1.u, n1.v);
Draws a vertex at 3D position (x, y, z) with texture coordinates (u, v), mapping the face texture to this point
if (meltFactor > 0.01) drawBowl();
Only renders the glass bowl when meltFactor is above 0.01 (i.e., Eric is at least slightly melted)
pop();
Restores the transformation matrix, undoing all the translate/scale/rotate calls made before push()
updateHintOpacity();
Fades the hint label to 25% opacity after ~10 seconds of no player interaction

updateFaceTexture(pg)

updateFaceTexture() demonstrates how to animate a 2D texture by drawing to an off-screen graphics buffer each frame. The key insight is that you can layer animations (pupils, eyelids, mouth) on top of a static base image, making the code efficient and the animation expressive. This technique is used everywhere: procedural animation, real-time texture manipulation, and performance optimization.

function updateFaceTexture(pg) {
  pg.clear();
  pg.image(staticFaceTex, 0, 0);

  // --- Blink logic ---
  if (!isBlinking && frameCount > nextBlinkFrame) {
    isBlinking = true;
    blinkFrame = 0;
  }
  if (isBlinking) {
    blinkFrame++;
    if (blinkFrame <= 4) {
      currentBlink = blinkFrame / 4;              // closing
    } else if (blinkFrame <= 8) {
      currentBlink = 1 - (blinkFrame - 4) / 4;    // opening
    } else {
      currentBlink = 0;
      isBlinking = false;
      nextBlinkFrame = frameCount + int(random(80, 260));
    }
  } else {
    currentBlink = 0;
  }

  // Pupils follow pointer
  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);

  // Eyelids for blink (skin-colored rectangles sliding down)
  if (currentBlink > 0.001) {
    pg.noStroke();
    pg.fill('#FFD1B3');
    let h = 60 * currentBlink;
    // Left eye lid
    pg.rect(256 - 60 - 28, 256 - 70, 56, h);
    // Right eye lid
    pg.rect(256 + 60 - 28, 256 - 70, 56, h);
  }

  // Talking mouth overlay (override original mouth)
  if (isTalking) {
    // Cover original mouth
    pg.noStroke();
    pg.fill('#FFD1B3');
    pg.rect(256 - 70, 256 + 60, 140, 60);

    // Animated open mouth
    let openH = 30 + noise(frameCount * 0.5) * 40;
    pg.fill('#631F28');
    pg.stroke('#4A151C');
    pg.strokeWeight(5);
    pg.ellipse(256, 256 + 80, 70, openH);
  }
}
Line-by-line explanation (24 lines)

🔧 Subcomponents:

calculation Pupil following let mx = constrain(mouseX - width / 2, -400, 400); let pupilOffX = map(mx, -400, 400, -12, 12); pg.ellipse(256 - 60 + pupilOffX, ...);

Maps mouse position to pupil offset, making eyes follow the pointer within a range

conditional Eyelid rendering if (currentBlink > 0.001) { let h = 60 * currentBlink; pg.rect(...); pg.rect(...); }

Draws eyelids as rectangles that slide down proportional to currentBlink value

conditional Talking mouth animation if (isTalking) { pg.fill('#FFD1B3'); pg.rect(...); let openH = 30 + noise(...) * 40; pg.ellipse(...); }

When isTalking is true, covers the static mouth and draws an animated open mouth using noise

pg.clear();
Clears the graphics buffer, removing all previously drawn pixels so we start fresh each frame
pg.image(staticFaceTex, 0, 0);
Draws the static face texture (pre-drawn in setup) as the base; all animations are layered on top
if (!isBlinking && frameCount > nextBlinkFrame) {
Checks if we're not currently blinking AND the scheduled blink time has arrived; if so, starts a new blink
isBlinking = true;
Sets the flag to true, entering the blinking state
blinkFrame = 0;
Resets the blink counter to 0; blinkFrame increments each frame while blinking to track animation progress
if (blinkFrame <= 4) { currentBlink = blinkFrame / 4; }
During frames 0–4, lerp currentBlink from 0 to 1 (eyes closing); 0/4=0, 4/4=1
} else if (blinkFrame <= 8) { currentBlink = 1 - (blinkFrame - 4) / 4; }
During frames 5–8, lerp currentBlink from 1 back to 0 (eyes opening); 1 - (4/4) = 0, 1 - (0/4) = 1
} else { currentBlink = 0; isBlinking = false; nextBlinkFrame = frameCount + int(random(80, 260)); }
After frame 8, blink is done: reset currentBlink to 0, end blinking, and schedule the next blink 80–260 frames away
} else { currentBlink = 0; }
If not blinking, set currentBlink to 0 (eyes fully open)
let mx = constrain(mouseX - width / 2, -400, 400);
Calculates mouse position relative to screen center, clamped to -400 to 400 range; this centers the reference point at the canvas middle
let pupilOffX = map(mx, -400, 400, -12, 12);
Maps mx from -400–400 to offset -12–12 pixels; leftmost mouse = -12, rightmost = +12, creating pupil tracking
let my = constrain(mouseY - height / 2, -400, 400);
Same as mx but for vertical mouse position
let pupilOffY = map(my, -400, 400, -12, 12);
Maps my to vertical pupil offset; topmost mouse = -12, bottommost = +12
pg.ellipse(256 - 60 + pupilOffX, 256 - 40 + pupilOffY, 20, 20);
Draws left pupil at base position (256-60, 256-40) plus the mouse-tracking offsets; 20×20 pixel circle
pg.ellipse(256 + 60 + pupilOffX, 256 - 40 + pupilOffY, 20, 20);
Draws right pupil; base position (256+60, 256-40) plus offsets, mirrored horizontally from left pupil
if (currentBlink > 0.001) {
Only draws eyelids if currentBlink is above 0.001 (eyes are at least slightly closed); avoids drawing invisible eyelids
let h = 60 * currentBlink;
Eyelid height proportional to currentBlink: when currentBlink=0, h=0 (no eyelid); when currentBlink=1, h=60 (fully closed)
pg.rect(256 - 60 - 28, 256 - 70, 56, h);
Draws left eyelid as a rectangle 56 pixels wide, h pixels tall, covering the left eye; positioned above the eye
pg.rect(256 + 60 - 28, 256 - 70, 56, h);
Draws right eyelid; same height as left, but at right eye position, creating symmetric blinking
if (isTalking) {
Only draws animated mouth if isTalking is true (text-to-speech is playing)
pg.fill('#FFD1B3');
Sets fill to the skin tone color to cover the static neutral mouth
pg.rect(256 - 70, 256 + 60, 140, 60);
Draws a rectangle covering the original mouth area (140 pixels wide, 60 tall), erasing it with skin tone
let openH = 30 + noise(frameCount * 0.5) * 40;
Uses Perlin noise to generate random mouth opening between 30 and 70 pixels; frequency 0.5 makes it wiggle slowly
pg.ellipse(256, 256 + 80, 70, openH);
Draws an ellipse (open mouth) 70 pixels wide and openH pixels tall; shape changes each frame creating talking animation

interactStart(sx, sy)

interactStart() is called when the player clicks or touches the canvas. It performs two critical tasks: (1) collision detection to figure out which body part was clicked, using distance calculations; and (2) context-aware speech generation to make Eric reactive and personable. This function shows how to layer gameplay logic (grabbing), haptics (vibration), and personality (random phrases) into a single interaction.

🔬 The collision detection uses `radius + 30` as the click zone size. What happens if you change it to `radius + 0` so you need to click more precisely on limbs, or `radius + 80` to make them easier to grab from farther away?

  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;
  lastInteractionFrame = frameCount;
  doVibrate(10);

  if (random() < 0.4 && physicsMode === 'ELASTIC') {
    let pool;
    switch (grabbedPart) {
      case 'head':
        pool = [
          "Careful with my face!",
          "Not the hair!",
          "My brain is stretching!"
        ];
        break;
      case 'lHand':
      case 'rHand':
        pool = [
          "High five!",
          "Nice to meet you!",
          "My arms are so elastic!"
        ];
        break;
      case 'lFoot':
      case 'rFoot':
        pool = [
          "I'm ticklish!",
          "My toes!",
          "I skipped leg day..."
        ];
        break;
      case 'torso':
        pool = [
          "That's my suit!",
          "Watch the tie!",
          "Gentle squeeze, please!"
        ];
        break;
      default:
        pool = ["Careful with the face!", "Whoa, stretchy!", "Ow, let go!"];
    }
    speak(random(pool));
  }
}
Line-by-line explanation (26 lines)

🔧 Subcomponents:

calculation Position anchor recording torsoNode.dragAnchor = torsoNode.pos.copy(); lHandNode.dragAnchor = lHandNode.pos.copy(); ...

Saves the starting position of each limb so drag calculations can measure relative motion

conditional Multi-part collision detection if (dist(...) < lHandNode.radius + 30) { grabbedPart = 'lHand'; } else if (...) { ... } else { ... }

Tests distance from click to each body part in priority order (hands, feet, torso, head); assigns grabbedPart to the closest

switch-case Context-aware dialogue selection switch (grabbedPart) { case 'head': pool = [...]; break; case 'lHand': ... }

Chooses different random phrases depending on which body part was grabbed, making Eric's reactions contextual

if (gameState !== 'PLAY') return;
Safety check: ignore clicks during HOME or CREDITS screens; only allow interaction during PLAY
grabStartScreen = createVector(sx, sy);
Stores the initial screen coordinates (sx, sy) where the mouse/touch began; used later to calculate drag distance
let wm = getWorldMouse(sx, sy);
Converts screen coordinates to 3D world coordinates using the projection math in getWorldMouse()
torsoNode.dragAnchor = torsoNode.pos.copy();
Records the torso's current position in dragAnchor; this serves as the reference point for calculating how far the torso has been dragged
lHandNode.dragAnchor = lHandNode.pos.copy();
Same for left hand: saves its starting position in case we need to calculate relative motion
let headAnchorY = torsoNode.pos.y - 260;
Calculates the Y position of the head anchor (260 pixels above torso); used in collision detection for clicking the head
if (dist(wm.x, wm.y, lHandNode.pos.x, lHandNode.pos.y) < lHandNode.radius + 30) {
Calculates distance from click to left hand's position; if closer than (radius + padding), the hand was clicked
grabbedPart = 'lHand';
Sets grabbedPart to identify which body part is being dragged; used later in draw() to apply forces to only this part
} else if (dist(wm.x, wm.y, rHandNode.pos.x, rHandNode.pos.y) < rHandNode.radius + 30) {
Checks right hand next in priority; each else-if creates a fallback chain
} else if (dist(wm.x, wm.y, lFootNode.pos.x, lFootNode.pos.y) < lFootNode.radius + 30) {
Checks left foot
} else if (dist(wm.x, wm.y, rFootNode.pos.x, rFootNode.pos.y) < rFootNode.radius + 30) {
Checks right foot
} else if (dist(wm.x, wm.y, torsoNode.pos.x, torsoNode.pos.y) < torsoNode.radius + 30) {
Checks torso (large radius so easier to grab)
} else if (dist(wm.x, wm.y, 0, headAnchorY) < radius + 40) {
Checks head; head is centered at (0, headAnchorY) in world space with radius 150 + padding 40
} else { return; }
If nothing was hit, exit early without setting isDragging or making sound; the click hit empty space
isDragging = true;
Sets the global isDragging flag to true; this tells draw() to enter drag-handling code
lastInteractionFrame = frameCount;
Records the current frame number; used by updateHintOpacity() to fade the hint after ~10 seconds of no interaction
doVibrate(10);
Vibrates the device for 10 milliseconds (on mobile); provides haptic feedback to the player
if (random() < 0.4 && physicsMode === 'ELASTIC') {
40% chance to speak when grabbing in ELASTIC mode; only speaks during solid state (not puddle mode)
let pool;
Declares a variable to hold an array of phrases; value set by the switch statement below
switch (grabbedPart) {
Begins a switch statement that selects a phrase array based on which body part was grabbed
case 'head': pool = ["Careful with my face!", "Not the hair!", "My brain is stretching!"]; break;
If head was grabbed, pool gets 3 head-related phrases; break exits the switch
case 'lHand': case 'rHand': pool = ["High five!", "Nice to meet you!", "My arms are so elastic!"]; break;
If either hand was grabbed, pool gets 3 hand-related phrases; case chaining lets both hands use the same pool
case 'lFoot': case 'rFoot': pool = ["I'm ticklish!", "My toes!", "I skipped leg day..."]; break;
If either foot was grabbed, pool gets 3 foot-related phrases
case 'torso': pool = ["That's my suit!", "Watch the tie!", "Gentle squeeze, please!"]; break;
If torso was grabbed, pool gets 3 torso-related phrases
default: pool = ["Careful with the face!", "Whoa, stretchy!", "Ow, let go!"]; }
Fallback pool if somehow grabbedPart is unknown (shouldn't happen, but safe coding)
speak(random(pool));
Picks a random phrase from the pool and speaks it aloud using the Web Speech API

Node3D class

Node3D is the workhorse of the soft-body physics. Each vertex on the head mesh is a Node3D that independently applies spring forces to reach its target position. The update() method implements Verlet integration, a physics technique that's simple, stable, and fast—perfect for interactive graphics. Key insight: by smoothly lerping between 'solid' and 'puddle' physics constants (stiffness and damping), the character transitions smoothly from springy-elastic to liquid-sloshy.

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 (15 lines)

🔧 Subcomponents:

calculation Node initialization constructor(x, y, z, u, v, lat, lon) { this.rest = createVector(x, y, z); this.puddleRest = ...; ... }

Stores position data (rest, puddleRest, target, pos, vel) and texture coordinates (u, v) and spherical math (lat, lon)

calculation Verlet spring integration 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);

Implements spring physics: calculates force toward target, applies damping friction, and updates position based on velocity

constructor(x, y, z, u, v, lat, lon) {
Creates a new Node3D with 3D coordinates (x, y, z), texture coordinates (u, v), and spherical angles (lat, lon)
this.rest = createVector(x, y, z);
Stores the resting position (where this node should be in normal, elastic mode); immutable for the lifetime of the node
this.puddleRest = createVector(x * 2.5, 260 + y * 0.1, z * 2.5);
Stores the puddle position: X and Z are scaled up 2.5× (widening), Y is shifted to 260 (down) with slight influence from original Y (creating a flattened shape inside the bowl)
this.target = createVector(x, y, z);
Target position the node is trying to reach; updated each frame by draw() based on interaction and mesh state
this.pos = createVector(x, y, z);
Current position; this is what gets rendered; updated each frame by the spring physics in update()
this.vel = createVector(0, 0, 0);
Velocity vector; accumulates forces and determines how fast the node moves each frame
this.u = u; this.v = v;
Texture coordinates (0–1 range); these never change and tell the graphics system how to map the 2D face texture onto this 3D point
this.lat = lat; this.lon = lon;
Latitude and longitude (in radians); stored so we can later check which nodes are on the jaw, cheeks, etc. for targeted deformations
let stiffness = lerp(0.15, 0.04, meltFactor);
Spring stiffness: solid Eric uses 0.15 (springy), puddle Eric uses 0.04 (loose and sloshy); lerp smoothly transitions
let damping = lerp(0.82, 0.94, meltFactor);
Velocity damping (friction): solid Eric uses 0.82 (bouncy), puddle Eric uses 0.94 (very sticky, slow motion); higher damping slows movement
let force = p5.Vector.sub(this.target, this.pos);
Calculates the vector from current position to target; the direction and magnitude of the 'pull' toward target
force.mult(stiffness);
Scales the force by stiffness; lower stiffness = weaker pull, takes longer to reach target (soft and jiggly)
this.vel.add(force);
Adds the force to velocity, causing acceleration toward the target (Newton's second law: F = ma)
this.vel.mult(damping);
Multiplies velocity by damping (0.82 or 0.94); this reduces velocity each frame, simulating friction and air resistance
this.pos.add(this.vel);
Updates position by adding velocity; the node moves by the amount stored in vel, completing the physics frame

LimbNode class

LimbNode manages the body parts (torso, hands, feet) with a clever dual-mode system: in normal mode, limbs maintain relative offsets to their parent (the torso), so the skeleton stays intact even while deforming; in puddle mode, all offsets change, making the character collapse into the bowl. The idle sway animation and randomized phase make Eric feel alive even when stationary. This class also demonstrates inheritance-like composition: the torso has no parent, while limbs always reference their parent's position.

🔬 This code makes limbs sway gently. The 0.05 controls speed (higher = faster oscillation), and the * 8 controls amplitude (distance swayed). What happens if you change frameCount * 0.05 to frameCount * 0.2 to make limbs sway 4× faster?

    // Idle gentle sway if not being pulled and not too melted
    if (!pullVec && parentPos && meltFactor < 0.25 && gameState === 'PLAY') {
      let sway = sin(frameCount * 0.05 + this.swayPhase) * 8;
      target.y += sway;
    }
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 (relative to torso)
    this.puddleOffset = puddleOffset;   // Melted position in the bowl
    this.dragAnchor = createVector(x, y, z);

    // Phase for subtle idle sway so limbs don't all move in sync
    this.swayPhase = random(TWO_PI);
  }
  
  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);
    }

    // Idle gentle sway if not being pulled and not too melted
    if (!pullVec && parentPos && meltFactor < 0.25 && gameState === 'PLAY') {
      let sway = sin(frameCount * 0.05 + this.swayPhase) * 8;
      target.y += sway;
    }

    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 (22 lines)

🔧 Subcomponents:

calculation Limb node initialization constructor(x, y, z, radius, localOffset, puddleOffset) { this.rest = createVector(x, y, z); ... this.swayPhase = random(TWO_PI); }

Stores limb position, collision radius, two offset modes (rigid and melted), and a random phase for idle animation

conditional Dual-mode target positioning if (!parentPos) { target = p5.Vector.lerp(...); } else { ... target = p5.Vector.add(parentPos, currentOffset); }

For torso: uses absolute resting position; for limbs: calculates target relative to parent (torso) position

conditional Idle animation if (!pullVec && parentPos && meltFactor < 0.25 && gameState === 'PLAY') { let sway = sin(...) * 8; target.y += sway; }

When limb is not being dragged and not too melted, adds gentle oscillating Y offset using sine wave

constructor(x, y, z, radius, localOffset, puddleOffset) {
Creates a limb node at (x, y, z) with a collision radius and two position offsets: localOffset for normal mode, puddleOffset for melted mode
this.radius = radius;
Collision radius for mouse-picking; larger values (45–120) make the limb easier to click
this.localOffset = localOffset;
For limbs: relative position to parent (torso) in normal mode, e.g., createVector(-160, 140, 0) means 160 left, 140 down from torso
this.puddleOffset = puddleOffset;
Position in puddle mode, e.g., createVector(-180, 40, 0) means the limb slides to this offset when melting
this.swayPhase = random(TWO_PI);
Random phase offset (0 to 2π) so each limb's idle sway starts at a different point in the sine wave; prevents all limbs moving in sync
if (!parentPos) {
If parentPos is null/undefined, this is the torso (no parent); uses absolute resting position
target = p5.Vector.lerp(this.rest, this.puddleOffset, meltFactor);
For torso: lerps between rest and puddle positions based on meltFactor; when meltFactor=0, target is rest; when 1, target is puddle
} else {
This is a limb (hand or foot) with a parent (the torso)
let currentOffset = p5.Vector.lerp(this.localOffset, this.puddleOffset, meltFactor);
Calculates the current offset by lerping between normal offset and puddle offset; smoothly transitions as meltFactor changes
target = p5.Vector.add(parentPos, currentOffset);
Sets target to parent position plus current offset; if torso moves to (100, 200), and hand offset is (-160, 140), hand targets (-60, 340)
if (!pullVec && parentPos && meltFactor < 0.25 && gameState === 'PLAY') {
Idle sway conditions: not being dragged, has a parent, not too melted (< 0.25), and game is in PLAY state
let sway = sin(frameCount * 0.05 + this.swayPhase) * 8;
Generates smooth oscillating sway: sin wave at frequency 0.05 per frame, with amplitude 8 pixels; each limb's swayPhase offset makes them sway out-of-sync
target.y += sway;
Modulates target Y position by adding sway; limbs bounce gently up/down when idle
if (pullVec) {
If this limb is being dragged, apply the drag force
target.add(pullVec.copy().limit(350));
Adds pull vector to target, moving the limb along with the drag; .limit(350) caps max pull distance to 350 pixels
let stiffness = lerp(0.15, 0.03, meltFactor);
Limb stiffness: solid = 0.15 (springy), puddle = 0.03 (very loose and sloshy)
let damping = lerp(0.82, 0.95, meltFactor);
Limb damping: solid = 0.82 (some bounce), puddle = 0.95 (very slow, syrupy motion)
let force = p5.Vector.sub(target, this.pos);
Spring force toward target
force.mult(stiffness);
Scale force by stiffness
this.vel.add(force);
Apply force to velocity
this.vel.mult(damping);
Apply damping (friction) to velocity
this.pos.add(this.vel);
Move position by velocity; this is where the limb actually moves

togglePhysics()

togglePhysics() demonstrates simple state management: switching between two modes and triggering all associated side effects (variable change, UI update, sound, haptics). This function is the glue that makes mode switching feel polished and responsive. Notice how button text updates to show the toggle effect, and contextual speech makes the mode change feel like Eric is responding to the player.

function togglePhysics() {
  if (physicsMode === 'ELASTIC') {
    physicsMode = 'PUDDLE';
    select('#puddle-btn').html('🧊 SOLID MODE');
    speak(random([
      "Oh no, I'm melting!",
      "I am water now!",
      "Splash!",
      "Call me Liquid Eric!"
    ]));
    doVibrate([30, 50, 30]);
  } else {
    physicsMode = 'ELASTIC';
    select('#puddle-btn').html('💧 PUDDLE MODE');
    speak(random([
      "I'm solid again!",
      "Freezing!",
      "Much better!",
      "Back in one piece!"
    ]));
    doVibrate(25);
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Physics mode toggle if (physicsMode === 'ELASTIC') { physicsMode = 'PUDDLE'; ... } else { physicsMode = 'ELASTIC'; ... }

Switches between ELASTIC and PUDDLE modes, updating button text and triggering reactive speech

calculation Button text synchronization select('#puddle-btn').html('🧊 SOLID MODE');

Changes button label to reflect the mode you'll enter if you click (toggle state)

if (physicsMode === 'ELASTIC') {
If currently in ELASTIC mode, switch to PUDDLE
physicsMode = 'PUDDLE';
Sets global physicsMode to PUDDLE; draw() uses this to decide how to interpolate meltFactor
select('#puddle-btn').html('🧊 SOLID MODE');
Changes the button text to show what will happen if you click it again (solidifying)
speak(random(["Oh no, I'm melting!", "I am water now!", "Splash!", "Call me Liquid Eric!"]));
Picks a random melting phrase and speaks it aloud, making Eric reactive to the mode change
doVibrate([30, 50, 30]);
Vibrates in a pattern: 30ms on, 50ms off, 30ms on—creates a more complex haptic feedback than a single buzz
} else {
If in PUDDLE mode, switch back to ELASTIC
physicsMode = 'ELASTIC';
Sets physicsMode to ELASTIC; meltFactor will now lerp toward 0
select('#puddle-btn').html('💧 PUDDLE MODE');
Restores button text to original puddle label
speak(random(["I'm solid again!", "Freezing!", "Much better!", "Back in one piece!"]));
Picks a random solidifying phrase and speaks it
doVibrate(25);
Simple 25ms vibration for solidifying (shorter than the melting pattern)

resetPose()

resetPose() is a complete state snapshot reset: it returns Eric to the exact pose he started in, zeroes all velocities (stopping motion), and clears any drag state. This function teaches snapshot state management—the idea of capturing and restoring a system to a known state. It's crucial for UX: players expect a RESET button to feel instantaneous and complete.

function resetPose() {
  physicsMode = 'ELASTIC';
  meltFactor = 0;

  let puddleBtn = select('#puddle-btn');
  if (puddleBtn) puddleBtn.html('💧 PUDDLE MODE');

  // Reset torso
  torsoNode.pos = torsoNode.rest.copy();
  torsoNode.vel.set(0, 0, 0);
  torsoNode.dragAnchor = torsoNode.pos.copy();

  // Reset limbs relative to torso
  [lHandNode, rHandNode, lFootNode, rFootNode].forEach(node => {
    node.pos = p5.Vector.add(torsoNode.pos, node.localOffset);
    node.vel.set(0, 0, 0);
    node.dragAnchor = node.pos.copy();
  });

  // Reset head mesh on top of the torso
  let headAnchor = p5.Vector.add(torsoNode.pos, createVector(0, -260, 0));
  for (let i = 0; i <= cols; i++) {
    for (let j = 0; j <= rows; j++) {
      let n = nodes[i][j];
      n.pos = p5.Vector.add(headAnchor, n.rest);
      n.target = n.pos.copy();
      n.vel.set(0, 0, 0);
    }
  }

  isDragging = false;
  grabbedPart = null;
  lastInteractionFrame = frameCount;
}
Line-by-line explanation (18 lines)

🔧 Subcomponents:

calculation Mode and melt state reset physicsMode = 'ELASTIC'; meltFactor = 0; let puddleBtn = select('#puddle-btn'); if (puddleBtn) puddleBtn.html('💧 PUDDLE MODE');

Resets physics mode to ELASTIC and meltFactor to 0, syncing UI button to match

calculation Torso and limbs reset to rest pose torsoNode.pos = torsoNode.rest.copy(); torsoNode.vel.set(0, 0, 0); [lHandNode, ...].forEach(node => { ... });

Resets all limbs to their resting positions and zeroes velocities so they stop moving

for-loop Head mesh vertices reset for (let i = 0; i <= cols; i++) { for (let j = 0; j <= rows; j++) { n.pos = p5.Vector.add(headAnchor, n.rest); ... } }

Resets all head mesh nodes to their original positions relative to the torso

physicsMode = 'ELASTIC';
Forces mode back to elastic (normal)
meltFactor = 0;
Instantly sets meltFactor to 0; Eric is no longer melted
let puddleBtn = select('#puddle-btn');
Gets the puddle button element from the DOM
if (puddleBtn) puddleBtn.html('💧 PUDDLE MODE');
Resets button text back to puddle mode label (assumes it was changed to SOLID MODE during puddle state)
torsoNode.pos = torsoNode.rest.copy();
Resets torso position to its resting pose; .copy() creates a new vector so we don't share references
torsoNode.vel.set(0, 0, 0);
Zeros out torso velocity; stops any momentum or jiggling
torsoNode.dragAnchor = torsoNode.pos.copy();
Updates the drag anchor to current position; ensures next drag calculation starts from here
[lHandNode, rHandNode, lFootNode, rFootNode].forEach(node => {
Array shorthand to iterate over all limb nodes in one loop
node.pos = p5.Vector.add(torsoNode.pos, node.localOffset);
Resets limb to rest position relative to torso; if torso is at (0, 260) and hand offset is (-160, 140), hand becomes (-160, 400)
node.vel.set(0, 0, 0);
Zeros limb velocity
node.dragAnchor = node.pos.copy();
Updates drag anchor
let headAnchor = p5.Vector.add(torsoNode.pos, createVector(0, -260, 0));
Calculates head position (260 above torso)
n.pos = p5.Vector.add(headAnchor, n.rest);
Resets each mesh node to its rest position relative to headAnchor
n.target = n.pos.copy();
Sets target equal to pos; physics will maintain this until next input
n.vel.set(0, 0, 0);
Zeros mesh node velocity
isDragging = false;
Cancels any active drag
grabbedPart = null;
Clears which body part was being grabbed
lastInteractionFrame = frameCount;
Records this interaction for hint fading

drawBody(headAnchor)

drawBody() demonstrates hierarchical 3D rendering: joints anchor to the torso, bones connect joints, and limbs follow nodes. The breathing animation adds life, and the melting scale transform shows how a single line of code can drive a complex visual transformation. This function is a masterclass in using transforms (push/pop, translate, scale, rotate) to build a complex articulated character.

🔬 This line scales the torso when melting: Y goes 1.0→0.2 (flattens), Z goes 1.0→1.5 (widens). What if you change the Z range to lerp(1.0, 2.5, meltFactor) to make puddle Eric spread even wider?

  scale(1.0, lerp(1.0, 0.2, meltFactor), lerp(1.0, 1.5, meltFactor));
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 (27 lines)

🔧 Subcomponents:

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

Creates subtle up/down breathing motion in elastic mode; zero breath in puddle mode (liquid doesn't breathe)

calculation Torso ellipsoid with melting transform push(); translate(tPos.x, tPos.y + breath, tPos.z); scale(1.0, lerp(1.0, 0.2, meltFactor), lerp(1.0, 1.5, meltFactor)); ... ellipsoid(...); pop();

Renders torso at torso position with breathing offset; scales vertically (Y) to flatten when melting, horizontally (Z) to widen

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

Calculates shoulder and hip positions relative to torso center, so joints move when torso moves

calculation Limb sphere and bone rendering fill('#E76F51'); push(); translate(...); sphere(35); pop(); drawBone(...); fill('#FFD1B3'); push(); translate(...); sphere(25); pop();

Draws joint spheres, connecting bones (cylinders), and hand/foot spheres in sequence for each limb

let breath = (physicsMode === 'ELASTIC') ? sin(frameCount * 0.05) * 5 : 0;
In ELASTIC mode, calculates a sine wave oscillation (amplitude 5) at frequency 0.05; in PUDDLE mode, breath is 0
let tPos = torsoNode.pos;
Caches torso position in a shorter variable name for readability
let neckTop = p5.Vector.add(headAnchor, createVector(0, 140, -20));
Calculates neck starting position (140 below head, -20 in Z); neck is a cylinder from here to torsoTop
let torsoTop = createVector(tPos.x, tPos.y - 100 + breath, tPos.z);
Calculates neck ending position (100 above torso, with breathing offset); at this point, neck meets torso
fill('#FFD1B3');
Sets fill color to skin tone for neck
drawBone(neckTop, torsoTop, 25);
Draws the neck as a cylinder (bone) from head to torso with thickness 25
push();
Saves the current transform state before translating/scaling for torso
translate(tPos.x, tPos.y + breath, tPos.z);
Moves to torso center position (includes breathing offset)
scale(1.0, lerp(1.0, 0.2, meltFactor), lerp(1.0, 1.5, meltFactor));
Scales: X stays 1.0 (no change), Y lerps 1.0→0.2 (flattens vertical), Z lerps 1.0→1.5 (widens depth); creates puddle effect
texture(torsoTex);
Applies the torso texture (E's on orange background)
rotateY(HALF_PI);
Rotates torso 90° around Y axis so the E texture faces the camera
ellipsoid(120, 140, 80);
Draws a 3D ellipsoid (squashed sphere) 120×140×80 pixels; the scale() above modifies this
pop();
Restores previous transform state; undoes translate/scale
let lShoulder = createVector(tPos.x - 100, tPos.y - 60 + breath, tPos.z);
Left shoulder 100 pixels left, 60 pixels above torso, with breathing
let rShoulder = createVector(tPos.x + 100, tPos.y - 60 + breath, tPos.z);
Right shoulder, mirrored (100 pixels right)
let lHip = createVector(tPos.x - 50, tPos.y + 110 + breath, tPos.z);
Left hip 50 pixels left, 110 pixels below torso
let rHip = createVector(tPos.x + 50, tPos.y + 110 + breath, tPos.z);
Right hip, mirrored
fill('#E76F51');
Sets fill to orange (shoulder color)
push(); translate(lShoulder.x, lShoulder.y, lShoulder.z); sphere(35); pop();
Draws left shoulder joint as a 35-pixel-radius sphere at shoulder position
drawBone(lShoulder, lHandNode.pos, 22);
Draws connecting bone (upper arm) from shoulder to hand with thickness 22
fill('#FFD1B3');
Changes fill to skin tone for hand
push(); translate(lHandNode.pos.x, lHandNode.pos.y, lHandNode.pos.z); sphere(25); pop();
Draws left hand as a 25-pixel sphere at lHandNode's current position (which is physics-driven and draggable)
fill('#2A9D8F');
Sets fill to teal (leg joint color)
push(); translate(lHip.x, lHip.y, lHip.z); sphere(35); pop();
Draws left hip joint as a 35-pixel sphere
drawBone(lHip, lFootNode.pos, 28);
Draws lower leg (bone) from hip to foot with thickness 28
fill('#E9C46A');
Sets fill to yellow (shoe color)
push(); translate(lFootNode.pos.x, lFootNode.pos.y + 15, lFootNode.pos.z + 15); ellipsoid(22, 18, 35); pop();
Draws left shoe as an ellipsoid at foot position, slightly offset (+15 Y and Z for ground contact

📦 Key Variables

gameState string

Tracks which screen the player is on: 'HOME' (menu), 'CREDITS' (credits), or 'PLAY' (game active); controls what renders and what input is allowed

let gameState = "HOME";
physicsMode string

Toggles between 'ELASTIC' (solid, springy physics) and 'PUDDLE' (liquid, soft physics); controls how physics constants are interpolated

let physicsMode = "ELASTIC";
meltFactor number

Ranges from 0 (fully solid) to 1 (fully liquid); smoothly interpolates all physics constants, positions, and scales between solid and puddle states

let meltFactor = 0;
nodes array (2D array of Node3D)

Stores all ~1225 vertices of the head mesh in a 2D grid (cols × rows); each node maintains physics state and target position

let nodes = [];
torsoNode, lHandNode, rHandNode, lFootNode, rFootNode LimbNode

Body part nodes with physics, collision radius, and dual rest/puddle positions; torso has no parent, limbs tether to torso

let torsoNode; let lHandNode;
isDragging boolean

True while the player is actively dragging a body part; controls whether draw() applies drag forces and changes cursor to 'grabbing'

let isDragging = false;
grabbedPart string

Identifies which body part was grabbed: 'head', 'torso', 'lHand', 'rHand', 'lFoot', 'rFoot', or null; determines which node receives drag forces

let grabbedPart = null;
currentBlink number

Ranges from 0 (eyes open) to 1 (eyes closed); drives eyelid height animation; updated by blink state machine in updateFaceTexture()

let currentBlink = 0;
isTalking boolean

True while Web Speech API is playing audio; controls mouth animation and eyelid behavior during speech

let isTalking = false;
faceTex, staticFaceTex, torsoTex p5.Renderer (graphics buffer)

Off-screen graphics objects: faceTex is updated every frame with animated face; staticFaceTex is drawn once in setup; torsoTex holds the E eyes on orange background

let faceTex = createGraphics(512, 512);
lastInteractionFrame number

Records the frameCount when the player last interacted; used by updateHintOpacity() to fade the hint label after ~10 seconds

let lastInteractionFrame = 0;
speechSynth SpeechSynthesis API object

Reference to browser's Web Speech API; used to play audio via speak() function

let speechSynth = window.speechSynthesis;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG draw() head mesh rendering normals

Normals are calculated from headAnchor every frame, but if headAnchor itself changes position unexpectedly, lighting can look discontinuous or flipped. Also, for very extreme mesh deformations, per-vertex normals may not be accurate to the actual deformed surface.

💡 Consider smoothing normals by averaging neighboring vertex normals (Gouraud shading), or use face normals (TRIANGLE_STRIP already helps with this). Alternatively, consider clamping headAnchor to a smoother interpolation path.

PERFORMANCE draw() and Node3D.update()

Every frame, the code calculates currentRest for all ~1225 head mesh nodes by lerping between rest and puddleRest, and similarly for limbs. This is redundant: lerp value (meltFactor) only changes every frame, not per-node. Same calculation done 1225× per frame.

💡 Pre-calculate currentRestFactor = lerp(1.0, 0.0, meltFactor) once per frame in draw(), then use it in all node updates. Or cache a 'current resting state' per node, updated only when meltFactor changes significantly (e.g., delta > 0.01).

PERFORMANCE updateFaceTexture()

The function calls pg.clear(), pg.image(staticFaceTex, 0, 0), and then redraws pupils, eyelids, and possibly a mouth every single frame. While staticFaceTex is drawn once, compositing it and layering new content is non-trivial work repeated 60 times per second.

💡 Use a dirty flag: only update faceTex if isTalking changed, currentBlink changed, or mouse moved (for pupils). When idle and silent, skip updating entirely and reuse the previous frame's texture.

STYLE interactStart() and draw()

The collision detection distances use magic numbers like `lHandNode.radius + 30`, `torsoNode.radius + 30`, `radius + 40`. These are inconsistent and hard to tweak uniformly.

💡 Define a constant at the top: const CLICK_PADDING = 30; then use it: dist(...) < node.radius + CLICK_PADDING. Makes one-place edits possible.

FEATURE speak() function

The speak() function does not queue utterances. If the player clicks TALK twice quickly, the second utterance interrupts the first (speechSynth.speak() stops the currently speaking utterance). This can feel jarring.

💡 Implement a speech queue: instead of calling speak() directly, push phrases to an array and have a frame-by-frame check that plays queued items only when the previous one finishes. Or use utterance.onend to chain the next phrase.

BUG resetPose() and draw()

resetPose() sets all positions immediately, but draw() uses meltFactor which may not be exactly 0 or 1 after resetPose(). This can leave Eric in an in-between melted state. Also, resetPose() doesn't update any render-time values that may have accumulated (e.g., blinkFrame, swayPhase references).

💡 resetPose() should also reset currentBlink = 0, isBlinking = false, blinkFrame = 0, and nextBlinkFrame = frameCount + random(40, 160) to ensure Eric's eyes are in a consistent state.

🔄 Code Flow

Code flow showing setup, buildui, draw, updatefacetexture, interactstart, node3d, limbnode, togglephysics, resetpose, drawbody

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

graph TD start[Start] --> setup[setup] setup --> canvas-init[Canvas and texture setup] setup --> face-mesh-init[Spherical head mesh generation] setup --> limb-init[Limb node creation] setup --> home-screen[HOME screen setup] setup --> buildui[buildui] setup --> draw[draw loop] draw --> physics-update[Physics engine tick] draw --> face-update[Face texture update] draw --> blink-state[Blink animation state machine] draw --> pupils[Pupil following] draw --> eyelids[Eyelid rendering] draw --> talking-mouth[Talking mouth animation] draw --> drag-calculation[Drag vector from interaction] draw --> head-mesh-targets[Head mesh target positions] draw --> jaw-animation[Talking jaw deformation] draw --> melt-interpolation[Melt factor smoothing] draw --> 3d-rendering[3D scene rendering] draw --> idle-sway[Idle animation] draw --> mode-swap[Physics mode toggle] draw --> button-update[Button text synchronization] draw --> state-reset[Mode and melt state reset] draw --> body-reset[Torso and limbs reset to rest pose] draw --> mesh-reset[Head mesh vertices reset] draw --> breathing[Breathing animation] draw --> torso-rendering[Torso ellipsoid with melting transform] draw --> joint-anchors[Joint position calculation] draw --> limb-rendering[Limb sphere and bone rendering] click setup href "#fn-setup" click canvas-init href "#sub-canvas-init" click face-mesh-init href "#sub-face-mesh-init" click limb-init href "#sub-limb-init" click home-screen href "#sub-home-screen" click buildui href "#fn-buildui" click draw href "#fn-draw" click physics-update href "#sub-physics-update" click face-update href "#sub-face-update" click blink-state href "#sub-blink-state" click pupils href "#sub-pupils" click eyelids href "#sub-eyelids" click talking-mouth href "#sub-talking-mouth" click drag-calculation href "#sub-drag-calculation" click head-mesh-targets href "#sub-head-mesh-targets" click jaw-animation href "#sub-jaw-animation" click melt-interpolation href "#sub-melt-interpolation" click 3d-rendering href "#sub-3d-rendering" click idle-sway href "#sub-idle-sway" click mode-swap href "#sub-mode-swap" click button-update href "#sub-button-update" click state-reset href "#sub-state-reset" click body-reset href "#sub-body-reset" click mesh-reset href "#sub-mesh-reset" click breathing href "#sub-breathing" click torso-rendering href "#sub-torso-rendering" click joint-anchors href "#sub-joint-anchors" click limb-rendering href "#sub-limb-rendering"

❓ Frequently Asked Questions

What visual experience does the 'elastic Eric but worst and better idk' sketch provide?

The sketch creates a dynamic 3D representation of a soft-body character with fluid-like movements, featuring unique lighting effects and an interactive melting puddle effect.

How can users interact with the 'elastic Eric but worst and better idk' sketch?

Users can interact with the sketch by manipulating the character's form and observing its responses to movement, while also using hints and reset options for a guided experience.

What creative coding techniques are showcased in the 'elastic Eric but worst and better idk' sketch?

This sketch demonstrates soft-body physics, dynamic animation techniques, and the integration of 3D rendering using p5.js's WEBGL capabilities.

Preview

elastic Eric but worst and better idk - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of elastic Eric but worst and better idk - Code flow showing setup, buildui, draw, updatefacetexture, interactstart, node3d, limbnode, togglephysics, resetpose, drawbody
Code Flow Diagram