AI Mind Reader - xelsed.ai

This sketch creates an interactive 'AI Mind Reader' where a glowing neon brain pulses with animated neural connections while the user types cryptic hints into a text box. Each hint is sent to the OpenAI API, and the AI's guess floats away from the brain as a fading neon text bubble, accompanied by spoken audio feedback via the Web Speech API.

🧪 Try This!

Experiment with the code by making these changes:

  1. Recolor the brain glow — Changing brainColor's RGB values instantly changes the glowing brain's hue.
  2. Make the background darker or lighter — background() is called every frame, so raising its values brightens the whole canvas instantly.
  3. Supersize the guess text — The size property controls how large each floating guess bubble's text renders, so widening this range makes some guesses appear huge.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch renders a pulsing, glowing brain made of bezier curves at the center of the screen, surrounded by dozens of animated neural connection lines that pulse with light using additive blending. When you type a cryptic hint into the input box and submit it, the sketch sends all your hints to the OpenAI Chat Completions API and displays the AI's guess as a floating, fading neon text bubble - while also speaking it aloud with the browser's built-in speech synthesis. The visual glow effect relies heavily on blendMode(ADD) combined with multiple semi-transparent stroke passes, a classic trick for faking bloom/neon lighting in p5.js without shaders.

The code is organized around a small set of building blocks: setup() and draw() run the animation loop, defineBrainPoints() and drawBrain() build and render the organic brain shape from a handful of control points using bezier(), and two ES6 classes - NeuralConnection and GuessBubble - encapsulate the pulsing lines and floating text bubbles as reusable objects. Studying this sketch teaches you how to combine p5.js's DOM functions (createInput, createButton) with an async fetch() call to a real API, how additive blending creates convincing glow, and how object-oriented classes keep animated particle-like elements clean and manageable.

⚙️ How It Works

  1. When the page loads, preload() decodes a scrambled API key, then setup() creates a full-window canvas, builds a text input and submit button, calculates the brain's control points based on screen size, and spawns 25 NeuralConnection objects with randomized positions and pulse speeds.
  2. Every frame, draw() paints a dark blue background, then loops through every neural connection calling update() and display() to animate their pulsing glow lines, before calling drawBrain() to redraw the glowing brain outline with six layered bezier-curve passes for a bloom effect.
  3. Still inside draw(), the sketch loops backward through the guesses array, updating and displaying each floating GuessBubble's position and fading opacity, removing any bubble whose alpha has dropped to zero.
  4. When the user types a hint and presses Enter or clicks 'Submit Hint', handleSubmit() fires: it stores the hint, disables the UI, and sends all accumulated hints as a single prompt to the OpenAI API asking for a JSON guess.
  5. Once a response arrives, the sketch parses the returned JSON (with a fallback for guesses wrapped in markdown code fences), creates a new GuessBubble to display the guess, and calls speakGuess() to read it aloud using the Web Speech API, then re-enables the input for another hint.
  6. If the browser window is resized, windowResized() rebuilds the canvas and recalculates the brain's control points so the glowing shape stays centered and proportional.

🎓 Concepts You'll Learn

Bezier curvesAdditive blending (blendMode(ADD))ES6 classes and objectsp5.js DOM elements (createInput/createButton)Async/await fetch API callsWeb Speech API (speechSynthesis)Particle-style animation with arrays of objectsXOR string encoding/decoding

📝 Code Breakdown

getApiKey()

This is a simple (and insecure) obfuscation technique: XOR encoding scrambles text using a shared secret number, and applying XOR again with the same key perfectly reverses it. It's a fun demonstration of bitwise operators but should never be used to protect real secrets in client-side code, since anyone can view the source and decode it themselves.

function getApiKey() {
  return atob(encoded).split('').map(c => String.fromCharCode(c.charCodeAt(0) ^ key)).join('');
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation XOR Decode Map atob(encoded).split('').map(c => String.fromCharCode(c.charCodeAt(0) ^ key)).join('')

Base64-decodes the encoded string, then XORs each character's char code with a fixed key to reverse the scrambling and reveal the real API key

atob(encoded)
Decodes the base64 string 'encoded' back into a raw scrambled string of characters
.split('')
Turns the string into an array of individual characters so they can be transformed one by one
.map(c => String.fromCharCode(c.charCodeAt(0) ^ key))
For each character, gets its numeric character code, XORs it with the secret 'key' number, and converts the result back into a character - this reverses the original XOR scrambling
.join('')
Glues the array of decoded characters back into a single string, producing the real API key

preload()

preload() is a special p5.js function that runs once before setup(), guaranteeing that any asynchronous loading (like decoding a key or loading assets) finishes before the sketch starts drawing.

function preload() {
  openaiApiKey = getApiKey();
  console.log("OpenAI API Key decoded.");
}
Line-by-line explanation (2 lines)
openaiApiKey = getApiKey();
Calls the decoding function and stores the resulting API key string in a global variable for later use
console.log("OpenAI API Key decoded.");
Logs a confirmation message to the browser console so you can verify decoding happened without printing the key itself

setup()

setup() runs once at the start of every p5.js sketch. It's the ideal place to create DOM elements, set drawing defaults, and populate arrays of objects that draw() will animate afterward.

🔬 This loop determines how many glowing neural lines exist. What happens visually if you lower it to 5? What if you push it up to 100 - does performance or clarity suffer?

  for (let i = 0; i < 25; i++) {
    neuralConnections.push(new NeuralConnection());
  }
function setup() {
  createCanvas(windowWidth, windowHeight);
  brainColor = color(0, 150, 255); // A blueish glow for the brain
  neonColor = color(0, 255, 255); // Cyan for general neon effects

  // Create UI elements using p5.js DOM functions
  inputElement = createInput('');
  inputElement.attribute('placeholder', 'What are you thinking?');
  inputElement.attribute('maxlength', '100'); // Limit hint length
  inputElement.changed(handleSubmit); // Submit on Enter key
  inputElement.parent('ui-container'); // Add to the HTML container

  buttonElement = createButton('Submit Hint');
  buttonElement.mousePressed(handleSubmit);
  buttonElement.parent('ui-container');

  // Define control points for the brain shape
  defineBrainPoints();

  // Initialize neural connections
  for (let i = 0; i < 25; i++) {
    neuralConnections.push(new NeuralConnection());
  }

  textAlign(CENTER, CENTER);
  textFont('Monospace');
  noStroke(); // Default to no stroke for text
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

for-loop Spawn Neural Connections for (let i = 0; i < 25; i++) { neuralConnections.push(new NeuralConnection()); }

Creates 25 NeuralConnection objects with random starting positions and adds them to the array that draw() will animate every frame

createCanvas(windowWidth, windowHeight);
Makes the canvas fill the entire browser window so the brain and effects scale with screen size
brainColor = color(0, 150, 255);
Stores a blue color object used later to tint the brain's glow
neonColor = color(0, 255, 255);
Stores a cyan color object reused for neural lines and floating guess text
inputElement = createInput('');
Creates an HTML text input element (a p5.js DOM function) starting empty
inputElement.changed(handleSubmit);
Registers handleSubmit to run whenever the input's value changes and loses focus (in practice, this fires when you press Enter)
inputElement.parent('ui-container');
Moves the input element into the #ui-container div defined in the HTML, so CSS can position it at the bottom of the screen
buttonElement = createButton('Submit Hint');
Creates a clickable button labeled 'Submit Hint'
buttonElement.mousePressed(handleSubmit);
Runs handleSubmit whenever the button is clicked
defineBrainPoints();
Calculates the coordinates of the brain's outline based on the current canvas size
for (let i = 0; i < 25; i++) { neuralConnections.push(new NeuralConnection()); }
Loops 25 times, each time creating a new NeuralConnection object and adding it to the neuralConnections array
textAlign(CENTER, CENTER);
Sets text drawing so both horizontal and vertical alignment are centered on the given coordinates
noStroke();
Turns off outlines by default so text and shapes drawn later don't get an unwanted border unless stroke() is called again

draw()

draw() is the animation heartbeat of every p5.js sketch, running roughly 60 times per second. Looping backward when deleting array items (using splice) inside a forward-iterating context is a common and important pattern to avoid bugs.

🔬 This loop removes bubbles once alpha reaches 0. What do you think would happen if you changed the condition to `guessBubble.alpha <= 200` - would bubbles disappear almost instantly?

  for (let i = guesses.length - 1; i >= 0; i--) {
    let guessBubble = guesses[i];
    guessBubble.update();
    guessBubble.display();
    if (guessBubble.alpha <= 0) {
      guesses.splice(i, 1); // Remove faded guesses
    }
  }
function draw() {
  background(20, 20, 40); // Very dark blue

  // Update and display neural connections
  for (let nc of neuralConnections) {
    nc.update();
    nc.display();
  }

  // Draw the central glowing brain
  drawBrain();

  // Update and display floating guesses
  for (let i = guesses.length - 1; i >= 0; i--) {
    let guessBubble = guesses[i];
    guessBubble.update();
    guessBubble.display();
    if (guessBubble.alpha <= 0) {
      guesses.splice(i, 1); // Remove faded guesses
    }
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop Update Neural Connections for (let nc of neuralConnections) { nc.update(); nc.display(); }

Animates and redraws every neural connection line each frame

for-loop Update and Remove Guess Bubbles for (let i = guesses.length - 1; i >= 0; i--) { let guessBubble = guesses[i]; guessBubble.update(); guessBubble.display(); if (guessBubble.alpha <= 0) { guesses.splice(i, 1); // Remove faded guesses } }

Animates each floating guess bubble, fades it out, and deletes it from the array once fully invisible, iterating backward so splice doesn't skip elements

background(20, 20, 40);
Repaints the whole canvas a dark navy color every frame, erasing the previous frame's drawing to create smooth animation
for (let nc of neuralConnections) { nc.update(); nc.display(); }
Loops through every neural connection object, updating its pulse position and then drawing it
drawBrain();
Calls the helper function that draws the glowing bezier-curve brain shape on top of the neural connections
for (let i = guesses.length - 1; i >= 0; i--) {
Loops through the guesses array backward - this is important because the loop body might remove items, and looping backward avoids skipping the next item after a removal
guessBubble.update();
Moves the bubble slightly and reduces its alpha (opacity) so it fades over time
guessBubble.display();
Draws the guess text at its current position and opacity
if (guessBubble.alpha <= 0) { guesses.splice(i, 1); }
Once a bubble has fully faded out, removes it from the array so it stops being processed and doesn't accumulate forever

defineBrainPoints()

Separating shape-point calculation into its own function makes it easy to recompute the shape whenever the window resizes, without duplicating math inside drawBrain() or windowResized().

function defineBrainPoints() {
  const cx = width / 2;
  const cy = height / 2;
  const bw = min(width, height) * 0.35; // Brain width
  const bh = min(width, height) * 0.45; // Brain height

  // Points to create a more organic brain shape with a central sulcus
  brainPoints = [
    createVector(cx, cy - bh * 0.5),          // Top center
    createVector(cx - bw * 0.4, cy - bh * 0.4), // Top-left lobe
    createVector(cx - bw * 0.5, cy - bh * 0.1), // Mid-left
    createVector(cx - bw * 0.4, cy + bh * 0.3), // Bottom-left lobe
    createVector(cx, cy + bh * 0.5),          // Bottom center
    createVector(cx + bw * 0.4, cy + bh * 0.3), // Bottom-right lobe
    createVector(cx + bw * 0.5, cy - bh * 0.1), // Mid-right
    createVector(cx + bw * 0.4, cy - bh * 0.4), // Top-right lobe
    createVector(cx, cy - bh * 0.5)           // Back to top center to close the loop
  ];
}
Line-by-line explanation (5 lines)
const cx = width / 2;
Finds the horizontal center of the canvas to use as the brain's origin point
const cy = height / 2;
Finds the vertical center of the canvas
const bw = min(width, height) * 0.35;
Calculates the brain's width as a fraction of whichever dimension (width or height) is smaller, keeping it proportional on any screen shape
const bh = min(width, height) * 0.45;
Calculates the brain's height similarly, slightly taller than it is wide
brainPoints = [ ... ];
Builds an array of nine p5.Vector points arranged around the center to form an organic lobed brain outline, which drawBrain() will later connect using bezier curves

drawBrain()

This function demonstrates a widely-used p5.js glow technique: draw the same shape multiple times with blendMode(ADD), decreasing opacity and increasing/decreasing stroke weight each pass, so the light 'blooms' outward convincingly without any actual lighting engine.

🔬 This sets up 6 layered passes from thick/faint to thin/bright. What happens if you reverse the map ranges (swap 20,100 to 100,20 and 10,2 to 2,10) so passes go from thin/bright to thick/faint instead?

  for (let i = 0; i < 6; i++) {
    blendMode(ADD); // Additive blending for glow
    const glowAlpha = map(i, 0, 5, 20, 100);
    const glowWeight = map(i, 0, 5, 10, 2);
function drawBrain() {
  noFill();
  strokeWeight(2);

  // Draw multiple passes for a bloom/glow effect
  for (let i = 0; i < 6; i++) {
    blendMode(ADD); // Additive blending for glow
    const glowAlpha = map(i, 0, 5, 20, 100);
    const glowWeight = map(i, 0, 5, 10, 2);
    stroke(brainColor.r, brainColor.g, brainColor.b, glowAlpha);
    strokeWeight(glowWeight);

    // Draw the brain outline using bezier curves
    // Connect points in sequence to form the brain shape
    bezier(
      brainPoints[0].x, brainPoints[0].y,
      brainPoints[1].x, brainPoints[1].y,
      brainPoints[2].x, brainPoints[2].y,
      brainPoints[3].x, brainPoints[3].y
    );
    bezier(
      brainPoints[3].x, brainPoints[3].y,
      brainPoints[4].x, brainPoints[4].y,
      brainPoints[5].x, brainPoints[5].y,
      brainPoints[6].x, brainPoints[6].y
    );
    bezier(
      brainPoints[6].x, brainPoints[6].y,
      brainPoints[7].x, brainPoints[7].y,
      brainPoints[8].x, brainPoints[8].y,
      brainPoints[8].x, brainPoints[8].y // Close the loop by repeating the last point
    );
  }
  blendMode(BLEND); // Reset blending mode to default
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

for-loop Bloom Layer Loop for (let i = 0; i < 6; i++) {

Repeats the brain drawing 6 times with decreasing stroke weight and increasing alpha, layering thin bright strokes over thick faint ones to fake a soft glow

calculation Three Bezier Segments bezier(brainPoints[0].x, ... brainPoints[3].x, brainPoints[3].y);

Draws three connected bezier curves that together trace the full lobed outline of the brain shape

noFill();
Ensures the bezier curves are drawn as outlines only, with no interior fill color
for (let i = 0; i < 6; i++) {
Repeats the entire glow-drawing process 6 times to build up layered brightness (a cheap way to simulate bloom without a shader)
blendMode(ADD);
Switches to additive blending so overlapping semi-transparent strokes brighten each other instead of just overlapping normally, creating a glow
const glowAlpha = map(i, 0, 5, 20, 100);
Maps the loop index (0 to 5) to an opacity between 20 and 100, so later passes are more opaque
const glowWeight = map(i, 0, 5, 10, 2);
Maps the loop index to a stroke thickness that shrinks from 10 down to 2, so the first passes are thick and faint (outer glow) and later passes are thin and bright (the sharp core line)
stroke(brainColor.r, brainColor.g, brainColor.b, glowAlpha);
Sets the outline color to the brain's blue tone with the calculated transparency for this pass
bezier(brainPoints[0].x, brainPoints[0].y, brainPoints[1].x, brainPoints[1].y, brainPoints[2].x, brainPoints[2].y, brainPoints[3].x, brainPoints[3].y);
Draws a smooth curve from point 0 to point 3, using points 1 and 2 as control handles that pull the curve into shape - this forms the top-left lobe
blendMode(BLEND);
Restores normal blending after the loop so later drawing (like guess text) isn't affected by additive glow

class NeuralConnection

This class shows how object-oriented programming keeps particle-like animations organized: each NeuralConnection manages its own state (position, speed, size) and knows how to update and draw itself, so draw() just needs to loop through the array.

🔬 Every time a pulse finishes, this resets the whole line to new random points. What happens visually if you remove the two randomPointNearBrain() calls so the line stays fixed but the pulse still loops?

    this.pulsePosition += this.pulseSpeed;
    if (this.pulsePosition > 1) {
      this.pulsePosition = 0; // Loop the pulse
      this.start = this.randomPointNearBrain(); // Re-randomize endpoints for variety
      this.end = this.randomPointNearBrain();
    }
class NeuralConnection {
  constructor() {
    this.start = this.randomPointNearBrain();
    this.end = this.randomPointNearBrain();
    this.pulsePosition = random(1); // 0 to 1, percentage along the line
    this.pulseSpeed = random(0.005, 0.02); // Speed of the pulse
    this.pulseSize = random(5, 15); // Maximum size of the pulse
  }

  randomPointNearBrain() {
    const cx = width / 2;
    const cy = height / 2;
    const radius = min(width, height) * 0.25; // Base radius for connections
    const angle = random(TWO_PI);
    return createVector(cx + radius * cos(angle) * random(0.8, 1.3), cy + radius * sin(angle) * random(0.8, 1.3));
  }

  update() {
    this.pulsePosition += this.pulseSpeed;
    if (this.pulsePosition > 1) {
      this.pulsePosition = 0; // Loop the pulse
      this.start = this.randomPointNearBrain(); // Re-randomize endpoints for variety
      this.end = this.randomPointNearBrain();
    }
  }

  display() {
    stroke(neonColor.r, neonColor.g, neonColor.b, 30);
    strokeWeight(1);
    line(this.start.x, this.start.y, this.end.x, this.end.y);

    const pulseX = lerp(this.start.x, this.end.x, this.pulsePosition);
    const pulseY = lerp(this.start.y, this.end.y, this.pulsePosition);

    blendMode(ADD);
    stroke(neonColor.r, neonColor.g, neonColor.b, 150);
    strokeWeight(this.pulseSize * sin(this.pulsePosition * PI));
    line(pulseX, pulseY, pulseX, pulseY); // Draw a point with strokeWeight
    blendMode(BLEND); // Reset blending mode
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Pulse Loop Reset if (this.pulsePosition > 1) { this.pulsePosition = 0; // Loop the pulse this.start = this.randomPointNearBrain(); // Re-randomize endpoints for variety this.end = this.randomPointNearBrain(); }

When a pulse finishes traveling the full line, resets it to the start and picks brand new random endpoints, so connections continuously reshuffle

this.start = this.randomPointNearBrain();
Picks a random starting point near the brain's outline using polar coordinates (angle + radius)
this.pulsePosition = random(1);
Sets where along the line (0 = start, 1 = end) the glowing pulse currently is, randomized so all connections aren't in sync
const angle = random(TWO_PI);
Picks a random direction in radians, covering a full circle (TWO_PI = 360 degrees in radians)
return createVector(cx + radius * cos(angle) * random(0.8, 1.3), cy + radius * sin(angle) * random(0.8, 1.3));
Converts the random angle and radius into x,y coordinates using trigonometry (cos/sin), with an extra random multiplier so points aren't all on a perfect circle
this.pulsePosition += this.pulseSpeed;
Advances the pulse a little further along the line every frame
const pulseX = lerp(this.start.x, this.end.x, this.pulsePosition);
Uses linear interpolation to find the exact x coordinate of the glowing pulse based on how far along the line it currently is
strokeWeight(this.pulseSize * sin(this.pulsePosition * PI));
Uses a sine wave over the pulse's 0-to-1 travel to make its thickness grow from 0, peak in the middle, then shrink back to 0 - creating a smooth glowing dot effect

class GuessBubble

GuessBubble is a great example of encapsulating temporary, self-destructing visual feedback (a common UI pattern) as its own class, keeping all its behavior - movement, fading, drawing - in one place instead of scattered through draw().

🔬 What happens if you multiply speed.x and speed.y by a growing factor over time (like this.speed.x *= 1.01) so bubbles accelerate outward as they fade?

  update() {
    this.x += this.speed.x;
    this.y += this.speed.y;
    this.alpha -= this.fadeSpeed; // Fade out
  }
class GuessBubble {
  constructor(guessData) {
    this.guess = guessData.guess;
    this.confidence = guessData.confidence;
    this.reasoning = guessData.reasoning;
    this.x = width / 2 + random(-min(width, height) * 0.2, min(width, height) * 0.2);
    this.y = height / 2 + random(-min(width, height) * 0.2, min(width, height) * 0.2);
    this.size = random(18, 24); // Vary text size
    this.alpha = 255; // Initial opacity
    this.speed = createVector(random(-0.5, 0.5), random(-0.5, 0.5)); // Slow, randomized movement
    this.fadeSpeed = random(0.5, 1.5); // Vary fade speed
  }

  update() {
    this.x += this.speed.x;
    this.y += this.speed.y;
    this.alpha -= this.fadeSpeed; // Fade out
  }

  display() {
    fill(neonColor.r, neonColor.g, neonColor.b, this.alpha);
    noStroke();
    textSize(this.size);
    text(this.guess, this.x, this.y);
  }
}
Line-by-line explanation (7 lines)
this.guess = guessData.guess;
Stores the text of the AI's guess (like 'banana') passed in from the parsed API response
this.x = width / 2 + random(-min(width, height) * 0.2, min(width, height) * 0.2);
Places the bubble near the center of the screen but with some random horizontal offset, so guesses don't all stack in exactly the same spot
this.speed = createVector(random(-0.5, 0.5), random(-0.5, 0.5));
Gives the bubble a slow, random drift direction using a p5.Vector, so it floats gently rather than staying still
this.x += this.speed.x;
Moves the bubble horizontally each frame according to its stored speed
this.alpha -= this.fadeSpeed;
Reduces opacity every frame so the guess gradually fades away and eventually gets removed by draw()
fill(neonColor.r, neonColor.g, neonColor.b, this.alpha);
Sets the text color to cyan with the bubble's current (fading) opacity
text(this.guess, this.x, this.y);
Draws the guess word at the bubble's current position

handleSubmit()

This function demonstrates the standard async/await pattern for calling a web API from JavaScript: build a request, await the response, parse the JSON body, and wrap everything in try/catch/finally so errors are handled gracefully and the UI never gets stuck disabled.

🔬 This guard stops empty submissions. What happens if you also reject single-character hints (like just 'a') by changing the condition?

  if (hint === '') {
    alert('Please enter a hint!');
    return;
  }
async function handleSubmit() {
  const hint = inputElement.value().trim();
  if (hint === '') {
    alert('Please enter a hint!');
    return;
  }

  hints.push(hint); // Add current hint to the list
  inputElement.value(''); // Clear the input field
  buttonElement.attribute('disabled', true); // Disable button during API call
  inputElement.attribute('disabled', true); // Disable input during API call

  console.log("Submitting hints to OpenAI:", hints);

  const prompt = `Based on these clues, guess what I am thinking: ${hints.join(', ')}. Return JSON: {guess:string, confidence:1-100, reasoning:string}`;

  try {
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + openaiApiKey
      },
      body: JSON.stringify({
        model: 'gpt-3.5-turbo',
        messages: [{ role: 'user', content: prompt }],
        max_tokens: 150,
        temperature: 0.7
      })
    });

    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(`OpenAI API error: ${response.status} - ${errorData.error.message}`);
    }

    const data = await response.json();
    const content = data.choices[0].message.content;
    console.log("OpenAI raw response:", content);

    let guessData;
    try {
      guessData = JSON.parse(content);
    } catch (e) {
      const jsonMatch = content.match(/\{[\s\S]*\}/);
      if (jsonMatch) {
        guessData = JSON.parse(jsonMatch[0]);
      } else {
        throw new Error('Could not parse AI response as JSON.');
      }
    }

    guesses.push(new GuessBubble(guessData));
    speakGuess(guessData.guess);

  } catch (error) {
    console.error('Error communicating with OpenAI:', error);
    alert('Error getting AI response. Please try again.');
  } finally {
    buttonElement.removeAttribute('disabled');
    inputElement.removeAttribute('disabled');
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

conditional Empty Hint Guard if (hint === '') { alert('Please enter a hint!'); return; }

Stops the function early if the user tries to submit a blank hint

conditional JSON Fallback Parsing try { guessData = JSON.parse(content); } catch (e) { const jsonMatch = content.match(/\{[\s\S]*\}/); if (jsonMatch) { guessData = JSON.parse(jsonMatch[0]); } else { throw new Error('Could not parse AI response as JSON.'); } }

Handles cases where the AI wraps its JSON answer in extra text by extracting just the {...} portion and parsing that instead

const hint = inputElement.value().trim();
Reads the current text from the input box and trims whitespace from both ends
hints.push(hint);
Adds this new hint to the running list of all hints given so far, since the AI needs the full context to guess accurately
buttonElement.attribute('disabled', true);
Disables the submit button while waiting for the API response, preventing duplicate submissions
const prompt = `Based on these clues, guess what I am thinking: ${hints.join(', ')}. Return JSON: {guess:string, confidence:1-100, reasoning:string}`;
Builds the text prompt sent to the AI, joining every hint into one sentence and explicitly asking for a JSON-shaped response
const response = await fetch('https://api.openai.com/v1/chat/completions', { ... });
Sends an HTTP POST request to OpenAI's chat completions endpoint and pauses (await) until a response arrives
if (!response.ok) { ... throw new Error(...); }
Checks if the API returned an error status and throws a descriptive error if so, which gets caught below
const content = data.choices[0].message.content;
Digs into the API's response structure to extract just the text the AI generated
guessData = JSON.parse(content);
Attempts to parse that text directly as JSON to get structured guess/confidence/reasoning fields
guesses.push(new GuessBubble(guessData));
Creates a new floating bubble object from the parsed guess data and adds it to the array draw() animates
speakGuess(guessData.guess);
Passes the guessed word to the speech function so the browser reads it aloud
} finally { buttonElement.removeAttribute('disabled'); inputElement.removeAttribute('disabled'); }
Regardless of success or failure, always re-enables the input and button so the user can try again

speakGuess()

This function uses the Web Speech API (not part of p5.js itself, but a standard browser API) to convert text into spoken audio, showing how creative coding sketches can tap into browser features beyond the canvas.

function speakGuess(text) {
  if ('speechSynthesis' in window) {
    const utterance = new SpeechSynthesisUtterance(text);
    utterance.pitch = 1; // Standard pitch
    utterance.rate = 1; // Normal speed
    utterance.volume = 1; // Full volume
    speechSynthesis.speak(utterance);
    console.log("Speaking guess:", text);
  } else {
    console.warn("Speech Synthesis not supported in this browser.");
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Speech Support Check if ('speechSynthesis' in window) { ... } else { console.warn(...); }

Checks whether the current browser supports text-to-speech before trying to use it, avoiding a crash on unsupported browsers

if ('speechSynthesis' in window) {
Checks whether the browser's global window object has a speechSynthesis feature available before trying to use it
const utterance = new SpeechSynthesisUtterance(text);
Creates a speech request object wrapping the text to be spoken
utterance.pitch = 1;
Sets the voice pitch to its default/normal level (range is typically 0 to 2)
utterance.rate = 1;
Sets speaking speed to normal (1 = default, lower is slower, higher is faster)
speechSynthesis.speak(utterance);
Tells the browser to actually speak the utterance out loud using its built-in text-to-speech engine

windowResized()

windowResized() is a built-in p5.js callback that fires automatically whenever the browser window changes size, making it the right place to keep responsive sketches correctly scaled.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  defineBrainPoints(); // Recalculate brain points based on new size
  // UI elements are parented to #ui-container which is flex-centered, so they reposition automatically.
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the p5.js canvas to match the browser window's new dimensions whenever it changes
defineBrainPoints();
Recalculates the brain's control points using the new width/height so the shape stays centered and correctly proportioned

📦 Key Variables

inputElement object

Holds a reference to the p5.js DOM text input element used to type hints

let inputElement;
buttonElement object

Holds a reference to the p5.js DOM button element used to submit a hint

let buttonElement;
hints array

Stores every hint the user has typed so far, sent together to the AI for context

let hints = [];
guesses array

Stores active GuessBubble objects currently floating and fading on screen

let guesses = [];
neuralConnections array

Stores all NeuralConnection objects that animate the pulsing lines around the brain

let neuralConnections = [];
brainPoints array

Stores the p5.Vector control points that define the brain's bezier-curve outline

let brainPoints = [];
brainColor object

A p5.Color used to tint the glowing brain outline blue

let brainColor;
neonColor object

A p5.Color (cyan) reused for neural connection lines and floating guess text

let neonColor;
openaiApiKey string

Holds the decoded OpenAI API key used to authenticate requests to the Chat Completions API

let openaiApiKey;
encoded string

A base64 + XOR scrambled version of the API key, stored as a constant so it isn't plainly visible in the source

const encoded = '...';
key number

The XOR key (a fixed byte value) used to scramble and unscramble the encoded API key

const key = 0x5A;

🔧 Potential Improvements (5)

Here are some ways this code could be enhanced:

BUG handleSubmit() - security

The OpenAI API key is decoded and used directly in client-side JavaScript, meaning anyone can open dev tools, read the network request, or simply run getApiKey() in the console to steal the real key. XOR obfuscation of a hardcoded string offers no real protection.

💡 Route API calls through a small server-side proxy (e.g. a serverless function) that holds the real API key server-side, and have the sketch call that proxy instead of OpenAI directly.

BUG handleSubmit()

If the AI's response can't be parsed as JSON at all (no {...} found), an Error is thrown but guessData may still be referenced afterward in some code paths, and there's no user-facing distinction between a network failure and a parsing failure.

💡 Give the parsing-failure alert its own distinct message (e.g. 'AI gave an unexpected response, try rephrasing your hint') so users understand what went wrong.

FEATURE GuessBubble class

The confidence and reasoning fields are stored on every GuessBubble but never displayed anywhere - only this.guess is rendered.

💡 Show confidence visually, e.g. by scaling bubble size or color brightness with this.confidence, and reveal this.reasoning on hover/click for extra insight into the AI's thinking.

PERFORMANCE drawBrain()

Six full passes of three bezier() calls run every single frame regardless of whether the brain shape or colors changed, which is somewhat wasteful on lower-end devices given the canvas fills the whole window.

💡 Consider caching the glow layers in an offscreen createGraphics() buffer that's only redrawn when brainPoints or brainColor change, then simply image()-ing that buffer each frame.

STYLE handleSubmit()

There's no maximum limit on how many hints can accumulate in the hints array before being sent to OpenAI, so a long session could produce an overly long, expensive prompt.

💡 Cap hints to the most recent N entries (e.g. hints.slice(-5)) when building the prompt, or clear hints once a high-confidence guess is returned.

🔄 Code Flow

Code flow showing getapikey, preload, setup, draw, definebrainpoints, drawbrain, neuralconnection, guessbubble, handlesubmit, speakguess, windowresized

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> definebrainpoints[definebrainpoints] draw --> update-connections[update-connections] draw --> update-guesses[update-guesses] draw --> drawbrain[drawbrain] click setup href "#fn-setup" click draw href "#fn-draw" click definebrainpoints href "#fn-definebrainpoints" click update-connections href "#sub-update-connections" click update-guesses href "#sub-update-guesses" click drawbrain href "#fn-drawbrain" definebrainpoints --> bezier-segments[bezier-segments] click bezier-segments href "#sub-bezier-segments" update-connections --> pulse-loop-reset[pulse-loop-reset] click pulse-loop-reset href "#sub-pulse-loop-reset" update-guesses --> empty-hint-guard[empty-hint-guard] click empty-hint-guard href "#sub-empty-hint-guard" update-guesses --> update-guesses-loop[Update Guess Bubbles Loop] update-guesses-loop --> update-guesses-fade[Fade Out Guess Bubbles] update-guesses-fade --> update-guesses-remove[Remove Guess Bubbles] click update-guesses-loop href "#sub-update-guesses" click update-guesses-fade href "#sub-update-guesses-fade" click update-guesses-remove href "#sub-update-guesses-remove" drawbrain --> glow-passes-loop[glow-passes-loop] click glow-passes-loop href "#sub-glow-passes-loop"

❓ Frequently Asked Questions

What visual elements can users expect to see in the AI Mind Reader sketch?

Users will see a glowing brain with pulsating neural connections and floating thought bubbles representing the AI's guesses.

How can users interact with the AI Mind Reader sketch?

Users can type cryptic clues into an input field and click a button to submit hints, allowing the AI to make guesses based on the provided information.

What creative coding techniques are showcased in the AI Mind Reader sketch?

The sketch demonstrates concepts like dynamic visual feedback, user input handling, and the integration of neural network-inspired visuals to enhance interactivity.

Preview

AI Mind Reader - xelsed.ai - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Mind Reader - xelsed.ai - Code flow showing getapikey, preload, setup, draw, definebrainpoints, drawbrain, neuralconnection, guessbubble, handlesubmit, speakguess, windowresized
Code Flow Diagram