AI Mood Color Morphing - xelsed.ai

This sketch pairs a live text input with a hypnotic field of twenty overlapping, noise-distorted blobs that continuously reshape themselves using Perlin noise and rotation. When you type a mood or phrase and press Enter, the sketch sends your text to OpenAI's GPT-4o-mini model, receives back a 5-color hex palette that matches the emotional tone of your words, and recolors every blob to match.

🧪 Try This!

Experiment with the code by making these changes:

  1. Slow down or speed up the morphing — Lowering the multiplier makes the blobs shift very slowly and calmly; raising it makes them writhe rapidly.
  2. Thin out the crowd of shapes — Fewer concentric shapes creates a sparser, more minimal composition instead of a dense layered look.
  3. Make the shapes fill the whole screen — Increasing the base size multiplier makes each blob dramatically larger relative to the canvas.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch draws twenty concentric, organically wobbling shapes built from curveVertex() and Perlin noise, all rotating and pulsing around the center of the screen. What makes it special is the text input at the top-left: type any mood, phrase, or sentence, press Enter, and the sketch sends that text to OpenAI's chat completions API, which analyzes the emotional tone and replies with five hex colors that get applied to the shapes live. Under the hood it combines classic p5.js generative techniques - noise(), map(), beginShape()/curveVertex() - with a real async fetch() call to an external AI API and a lightweight (if insecure) XOR scheme to hide the API key in the source.

The code is organized around a small set of global state variables (the current palette, a loading flag, and a 'has a palette ever been generated' flag) that get read every frame inside draw(). setup() builds the HTML input field and wires up an event listener; handleInput() and callOpenAI() handle the network request and response parsing; draw() is a pure visual function that never touches the network, it just reads whatever colors are currently stored in currentPalette. Studying this sketch teaches you how to bridge an async API call with a continuously running p5.js animation loop without blocking or freezing the visuals.

⚙️ How It Works

  1. When the page loads, setup() creates a full-window canvas and an HTML text input positioned in the top-left corner, then attaches a 'changed' event listener that fires when you press Enter or click away from the field
  2. Every frame, draw() clears the background, then loops 20 times to draw concentric blob shapes: for each blob it picks a color from currentPalette, cycles the stroke weight, and traces 100 points around a circle whose radius is perturbed by 3D Perlin noise (using the shape index, angle, and a slowly increasing time offset as noise coordinates)
  3. Because the noise offset is based on frameCount, the exact same shape drawn last frame morphs slightly this frame, and because each shape also rotates using offset multiplied by its own index, the layers drift out of sync with each other, creating a swirling, breathing effect
  4. When you type text and press Enter, handleInput() reads the input's value and, if it isn't empty, calls callOpenAI(text), which sets isLoading to true so draw() shows an 'Analyzing mood...' message instead of (or alongside) the blobs
  5. callOpenAI() decrypts a stored API key via XOR, builds a chat completion request asking GPT-4o-mini to return exactly 5 hex colors matching the text's mood, and awaits the fetch() response
  6. Once the response arrives, a regular expression extracts any 6-digit hex codes from the AI's reply; if exactly 5 are found, currentPalette is replaced with them and paletteGenerated is set true, so the next frame of draw() immediately starts painting the blobs with the new mood-matched colors

🎓 Concepts You'll Learn

Perlin noise for organic motionasync/await and fetch() API callsDOM element creation with createInputCustom shapes with beginShape/curveVertexmap() for range conversionEvent-driven programming (changed handler)Basic (weak) client-side obfuscation with XOR

📝 Code Breakdown

getApiKey()

XOR is a simple, symmetric bit-flipping operation often used for lightweight obfuscation, but it is NOT real encryption - anyone who opens the browser's dev tools can run this exact function and print the key. This is a good example of why secret keys should never live in client-side JavaScript.

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

🔧 Subcomponents:

calculation XOR Decoding Map const bytes = atob(encoded).split('').map(c => c.charCodeAt(0) ^ key);

Base64-decodes the stored string, then flips every character's bits using XOR with a fixed key to reveal the original API key characters

const bytes = atob(encoded).split('').map(c => c.charCodeAt(0) ^ key);
atob() decodes the Base64 string into raw text, .split('') breaks it into individual characters, and .map() converts each character to its numeric code then XORs it with 'key' (0x5A) to undo the original encoding
return String.fromCharCode(...bytes);
Converts the array of XOR-decoded numeric codes back into a readable string - the actual OpenAI API key

setup()

setup() runs once when the sketch starts. Beyond creating the canvas, it's also where you set up any HTML DOM elements (like this text input) using p5.js's createInput(), createButton(), etc., which live alongside the canvas in the page.

function setup() {
  createCanvas(windowWidth, windowHeight);

  // Create the input field
  moodInput = createInput('Type your mood or text here...');
  moodInput.position(10, 10); // Position relative to the canvas
  moodInput.size(300, 30);
  moodInput.style('font-size', '16px');
  moodInput.style('padding', '5px');
  moodInput.style('border', 'none');
  moodInput.style('border-radius', '5px');
  moodInput.style('outline', 'none');
  moodInput.style('z-index', '1000'); // Ensure it's above the canvas

  // Attach an event listener for when the user presses Enter or tabs out
  moodInput.changed(handleInput); // https://p5js.org/reference/#/p5.Element/changed
}
Line-by-line explanation (6 lines)
createCanvas(windowWidth, windowHeight);
Makes the canvas fill the entire browser window rather than a fixed size
moodInput = createInput('Type your mood or text here...');
Creates an HTML text input element (not a canvas drawing) with placeholder-like starting text, and stores a reference to it in the global moodInput variable
moodInput.position(10, 10); // Position relative to the canvas
Places the input box 10 pixels from the left and top of the page
moodInput.size(300, 30);
Sets the input field's width and height in pixels
moodInput.style('font-size', '16px');
Applies a CSS style directly to the input element - here, making the text readable
moodInput.changed(handleInput); // https://p5js.org/reference/#/p5.Element/changed
Registers handleInput() as the function to run whenever the input's value changes and the field loses focus or the user presses Enter

handleInput()

This function acts as the bridge between a DOM event (the input changing) and the async API logic. Keeping it small and focused makes it easy to add extra checks later, like a minimum character length.

function handleInput() {
  const text = moodInput.value();
  if (text.trim() !== '') { // Only call API if there's actual text
    callOpenAI(text);
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Empty Text Guard if (text.trim() !== '') { // Only call API if there's actual text

Prevents wasting an API call when the input is blank or only whitespace

const text = moodInput.value();
Reads the current text typed into the input field
if (text.trim() !== '') { // Only call API if there's actual text
.trim() removes leading/trailing spaces so a field containing only spaces is treated as empty; this check stops empty submissions from triggering an API call
callOpenAI(text);
Passes the typed text to the async function that talks to OpenAI

callOpenAI()

This function demonstrates the standard async/await pattern for calling a REST API from JavaScript: build a request payload, await the fetch, check for errors, parse the JSON response, and always clean up state in a finally block. p5.js's draw() loop keeps running independently while this async function waits for the network, which is why the loading flag pattern is necessary.

🔬 This only accepts a new palette when exactly 5 colors are found. What happens if you relax it to colors.length >= 3 and slice the array to the first 5 - would more mood descriptions successfully update the visuals?

    if (colors && colors.length === 5) {
      currentPalette = colors;
      paletteGenerated = true;
      console.log('New Palette:', currentPalette);
    } else {
async function callOpenAI(text) {
  isLoading = true;
  moodInput.attribute('disabled', ''); // Disable input during loading
  moodInput.value('Analyzing mood...');

  const apiKey = getApiKey();
  const url = 'https://api.openai.com/v1/chat/completions';

  // Data payload for the OpenAI API request
  const data = {
    model: 'gpt-4o-mini', // Updated to gpt-4o-mini as requested
    messages: [
      {
        role: 'system',
        content: 'You are a helpful assistant that analyzes mood and provides color palettes. You must always return exactly 5 distinct hex color codes, separated by commas. Do not include any other text except the hex codes. Example: #RRGGBB, #RRGGBB, #RRGGBB, #RRGGBB, #RRGGBB'
      },
      {
        role: 'user',
        content: `Analyze the mood of this text and suggest a 5-color hex palette that matches. Text: "${text}"`
      }
    ],
    max_tokens: 100, // Limit the response length
    temperature: 0.7, // Creativity level
    response_format: { type: "text" } // Ensure the response is plain text for easier parsing
  };

  // Headers for the API request, including authorization
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${apiKey}`
  };

  try {
    // Send the POST request using the browser's native fetch API
    const response = await fetch(url, {
      method: 'POST',
      headers: headers,
      body: JSON.stringify(data)
    });

    if (!response.ok) { // Check for HTTP errors
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const responseData = await response.json();
    const content = responseData.choices[0].message.content; // Extract AI's generated content

    // Use a regular expression to find all valid 6-digit hex color codes in the response
    const colors = content.match(/#[0-9a-fA-F]{6}/g);

    if (colors && colors.length === 5) {
      currentPalette = colors;
      paletteGenerated = true;
      console.log('New Palette:', currentPalette);
    } else {
      console.warn('OpenAI response did not contain exactly 5 hex colors as expected:', content);
      // If parsing fails, currentPalette remains unchanged
    }
  } catch (error) {
    console.error('Error calling OpenAI API:', error);
    // If API call fails, currentPalette remains unchanged
  } finally {
    isLoading = false;
    moodInput.removeAttribute('disabled'); // Re-enable input
    moodInput.value(text); // Restore user's original text
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Try/Catch/Finally Network Handling try { const response = await fetch(url, {

Wraps the network call so any failure (bad connection, HTTP error, malformed JSON) is caught gracefully instead of crashing the sketch

conditional Exactly-5-Colors Check if (colors && colors.length === 5) {

Only replaces the visual palette if the AI's reply contained exactly the expected 5 hex codes, protecting against malformed responses

isLoading = true;
Flips a global flag so draw() shows the 'Analyzing mood...' loading text instead of nothing
moodInput.attribute('disabled', '');
Disables the text input so the user can't type or submit again while a request is in flight
const apiKey = getApiKey();
Decrypts the stored API key right before it's needed, rather than keeping it decrypted in memory the whole time
const response = await fetch(url, {
await pauses this async function (but NOT the rest of the sketch) until the network request to OpenAI completes
if (!response.ok) { // Check for HTTP errors
fetch() doesn't throw on HTTP error codes like 401 or 500 by default, so this manually checks response.ok and throws an error if the request failed
const colors = content.match(/#[0-9a-fA-F]{6}/g);
A regular expression scans the AI's text reply for anything that looks like a 6-digit hex color (e.g. #A1B2C3) and returns all matches as an array
if (colors && colors.length === 5) {
Only accepts the new palette if exactly 5 valid hex colors were found - guards against the AI returning too few, too many, or malformed colors
} finally {
The finally block always runs, whether the request succeeded or failed, ensuring the input is always re-enabled and the loading state is always cleared

draw()

draw() runs continuously (roughly 60 times per second) and is where all visual output happens. Notice it never calls the network directly - it only reads currentPalette and isLoading, which are updated asynchronously elsewhere. This separation of concerns (animation loop vs. async data fetching) is a key pattern for combining p5.js with any external API.

🔬 This loop draws 20 layered shapes with increasing stroke weight. What happens if you reverse the map() range to map(i, 0, numShapes, 5, 1) so outer shapes get thinner instead of thicker?

  for (let i = 0; i < numShapes; i++) {
    const colorIndex = i % currentPalette.length; // Cycle through the palette colors
    stroke(currentPalette[colorIndex]); // Set stroke color
    strokeWeight(map(i, 0, numShapes, 1, 5)); // Gradually increasing stroke weight

🔬 Each shape rotates at a speed based on offset * (i * 0.05). What happens if you remove the i multiplier entirely so every shape rotates at exactly the same speed - would the layered, out-of-sync swirling effect disappear?

      const x = width / 2 + r * cos(angle + offset * (i * 0.05)); // X position with offset rotation
      const y = height / 2 + r * sin(angle + offset * (i * 0.05)); // Y position with offset rotation
function draw() {
  background(0, 0, 10); // Dark background for contrast
  noFill();

  // Loading indicator
  if (isLoading) {
    fill(255);
    noStroke();
    textAlign(CENTER, CENTER); // https://p5js.org/reference/#/p5/textAlign
    textSize(24); // https://p5js.org/reference/#/p5/textSize
    text('Analyzing mood...', width / 2, height / 2); // https://p5js.org/reference/#/p5/text
    noFill();
  } else if (!paletteGenerated && moodInput.value() === 'Type your mood or text here...') {
    // Initial instruction hint
    fill(255, 180);
    noStroke();
    textAlign(CENTER, CENTER);
    textSize(18);
    text('Press Enter after typing to generate a palette.', width / 2, height / 2 + 50);
    noFill();
  }

  // Abstract morphing visual
  const numShapes = 20; // Number of concentric shapes
  const shapeSize = min(width, height) * 0.15; // Base size of the shapes
  const offset = frameCount * 0.005; // Time offset for noise and animation

  for (let i = 0; i < numShapes; i++) {
    const colorIndex = i % currentPalette.length; // Cycle through the palette colors
    stroke(currentPalette[colorIndex]); // Set stroke color
    strokeWeight(map(i, 0, numShapes, 1, 5)); // Gradually increasing stroke weight

    beginShape(); // Start drawing a custom shape
    for (let j = 0; j < 100; j++) { // 100 vertices for a smooth curve
      const angle = map(j, 0, 99, 0, TWO_PI); // Angle from 0 to 360 degrees
      const r = map(noise(i * 0.1, j * 0.01, offset), 0, 1, shapeSize * 0.5, shapeSize); // Use noise for organic radius variation
      const x = width / 2 + r * cos(angle + offset * (i * 0.05)); // X position with offset rotation
      const y = height / 2 + r * sin(angle + offset * (i * 0.05)); // Y position with offset rotation
      curveVertex(x, y); // Add a vertex for the curved shape
    }
    endShape(CLOSE); // End the shape and close it
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Loading / Hint Message Switch if (isLoading) { fill(255);

Shows either an 'Analyzing mood...' message while waiting for the API, or a first-time hint telling the user to press Enter, but never both

for-loop Concentric Shapes Loop for (let i = 0; i < numShapes; i++) {

Draws 20 layered blob outlines, each cycling through the current palette's colors and getting progressively thicker strokes

for-loop Vertex Generation Loop for (let j = 0; j < 100; j++) { // 100 vertices for a smooth curve

Computes 100 points around a circle, each with a noise-perturbed radius, to build one smooth organic blob outline

background(0, 0, 10); // Dark background for contrast
Repaints the whole canvas a near-black color every frame, which both clears the previous frame and makes the bright palette colors pop
const offset = frameCount * 0.005; // Time offset for noise and animation
frameCount increases by 1 every frame; multiplying it by a small number creates a slowly increasing value used to animate both the noise pattern and the rotation over time
for (let i = 0; i < numShapes; i++) {
Loops once for each of the 20 concentric shapes, i acts as both a color index and a unique 'seed' for each shape's noise pattern
const colorIndex = i % currentPalette.length; // Cycle through the palette colors
The modulo operator wraps the shape index back into range 0-4 so with only 5 colors but 20 shapes, colors repeat in a cycle
strokeWeight(map(i, 0, numShapes, 1, 5)); // Gradually increasing stroke weight
map() converts the shape's index (0 to 20) into a stroke thickness range (1 to 5), so outer shapes are drawn with thicker lines than inner ones
const r = map(noise(i * 0.1, j * 0.01, offset), 0, 1, shapeSize * 0.5, shapeSize); // Use noise for organic radius variation
noise() returns a smooth pseudo-random value between 0 and 1 based on three coordinates (shape index, vertex index, time); map() stretches that into a radius between half and full shapeSize, giving each point on the curve a slightly different distance from the center
const x = width / 2 + r * cos(angle + offset * (i * 0.05)); // X position with offset rotation
Calculates the horizontal position of this vertex using trigonometry - a point at distance r from the center, at a rotating angle that spins faster for shapes with higher index i
endShape(CLOSE); // End the shape and close it
Finishes the custom shape and connects the last vertex back to the first, closing the loop into a continuous blob outline

windowResized()

windowResized() is a special p5.js callback that automatically fires whenever the browser window changes size. Pairing it with createCanvas(windowWidth, windowHeight) in setup() is the standard way to build full-screen responsive sketches.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight); // https://p5js.org/reference/#/p5/resizeCanvas
  // Re-position the input field if needed (p5 handles this automatically if using .position())
  // moodInput.position(10, 10);
}
Line-by-line explanation (1 lines)
resizeCanvas(windowWidth, windowHeight); // https://p5js.org/reference/#/p5/resizeCanvas
Whenever the browser window is resized, this rebuilds the canvas to match the new width and height so the visuals always fill the screen

📦 Key Variables

encoded string

Stores the Base64-encoded, XOR-obfuscated OpenAI API key as a constant string embedded in the source code

const encoded = 'KTF3Kig1MHcI...';
key number

The XOR key (0x5A) used both to obfuscate and later de-obfuscate the API key

const key = 0x5A;
moodInput object

Holds a reference to the p5.Element text input so its value, styling, and enabled/disabled state can be controlled from anywhere in the sketch

let moodInput;
currentPalette array

Stores the 5 hex color strings currently used to draw the shapes - starts as a default vibrant palette and gets replaced once the AI responds

let currentPalette = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#00FFFF'];
isLoading boolean

Tracks whether an API request is currently in progress, so draw() can show a loading message and the input can be disabled

let isLoading = false;
paletteGenerated boolean

Records whether the user has ever successfully generated a palette, used to decide whether to still show the initial instruction hint

let paletteGenerated = false;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG getApiKey() / encoded constant

The OpenAI API key is embedded directly in client-side JavaScript and only lightly obfuscated with a reversible XOR cipher, meaning anyone can open the browser console, call getApiKey(), and steal the key to run up charges on the developer's OpenAI account.

💡 Move the API call to a small backend proxy (serverless function, Node server, etc.) that holds the real key server-side, and have the sketch call that proxy instead of api.openai.com directly.

BUG draw() initial hint condition

The hint message relies on comparing moodInput.value() to the exact string 'Type your mood or text here...', so if a user types that exact phrase (or the placeholder text is ever changed), the hint's behavior breaks unexpectedly.

💡 Track a dedicated boolean like hasUserTyped that flips to true the first time the input value changes, instead of string-comparing against the placeholder text.

PERFORMANCE draw() nested loops

Every frame recalculates 20 shapes x 100 vertices = 2000 noise() calls plus trig functions, which is fine on modern desktops but can strain low-power devices or mobile browsers, especially combined with a full-window canvas.

💡 Consider reducing numShapes or vertex count on smaller screens (check windowWidth/windowHeight in setup) or cache/reuse noise values across frames when the offset change is small.

FEATURE handleInput() / callOpenAI()

There's no visible error feedback to the user if the OpenAI request fails or returns an unparseable response - the console.warn/console.error messages are only visible in dev tools, so a normal user just sees nothing happen.

💡 Add a temporary on-canvas or on-page message (e.g. 'Couldn't generate a palette, try again') that appears briefly when the catch block or the colors.length !== 5 branch runs.

🔄 Code Flow

Code flow showing getapikey, setup, handleinput, callopenai, draw, windowresized

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> loadingmessage[loading-message] draw --> emptycheck[empty-check] draw --> shapesloop[shapes-loop] draw --> verticesloop[vertices-loop] draw --> colorcountcheck[color-count-check] draw --> tryfetch[try-fetch] draw --> callopenai[callopenai] callopenai --> xordecode[xor-decode] xordecode --> tryfetch loadingmessage --> draw emptycheck --> draw shapesloop --> draw verticesloop --> draw colorcountcheck --> draw tryfetch --> draw click setup href "#fn-setup" click draw href "#fn-draw" click loadingmessage href "#sub-loading-message" click emptycheck href "#sub-empty-check" click shapesloop href "#sub-shapes-loop" click verticesloop href "#sub-vertices-loop" click colorcountcheck href "#sub-color-count-check" click tryfetch href "#sub-try-fetch" click xordecode href "#sub-xor-decode" click callopenai href "#fn-callopenai"

❓ Frequently Asked Questions

What type of visual experience does the AI Mood Color Morphing - XeLseDai sketch provide?

This sketch creates an abstract visual experience that morphs colors dynamically based on the emotional analysis of user-inputted moods or phrases.

How can users interact with the AI Mood Color Morphing sketch?

Users can type a mood or phrase into the input field, and upon submission, the sketch analyzes the input and transforms the visual elements with matching color palettes.

What creative coding techniques are showcased in the AI Mood Color Morphing sketch?

The sketch demonstrates techniques such as API integration for real-time mood analysis and dynamic color palette generation, highlighting the intersection of art and artificial intelligence.

Preview

AI Mood Color Morphing - xelsed.ai - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Mood Color Morphing - xelsed.ai - Code flow showing getapikey, setup, handleinput, callopenai, draw, windowresized
Code Flow Diagram