AI Quest Master v2 - Fantasy RPG - xelsed.ai

This sketch builds a branching fantasy text adventure where a simulated AI Quest Master drives the story through a big lookup table of narrative states. As the story moves between forest, cave, castle, and plains locations, the p5.js canvas procedurally redraws a matching background scene while DOM elements outside the canvas display health, inventory, and clickable choice buttons.

🧪 Try This!

Experiment with the code by making these changes:

  1. Grow a denser forest — Raising the tree loop count makes the forest scene noticeably more crowded with trunks and leaves.
  2. Speed up the narrator — Increasing the speech rate makes the SpeechSynthesis narration sound fast and excited instead of calm and even.
  3. Recolor the full-health bar — Changing the healthy-state hex color instantly changes the color of the bar whenever health is above 70%.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch is a text-adventure RPG dressed up as an AI Quest Master: every choice you click looks up a matching entry in a big JavaScript array of pre-written story states, then updates your health, inventory, and location. What makes it visually interesting is that the p5.js canvas doesn't just sit there - drawScene() reads gameState.location and procedurally paints a completely different scene (leafy forest, jagged cave, stone castle, or rolling plain) using random(), rect(), triangle(), ellipse() and beginShape()/bezierVertex() shapes rather than any loaded images.

The code is organized around a single gameState object that holds health, inventory, location and story history, a giant mockAdventureStates array that acts as the fake 'AI', and a family of small update functions (updateHealthBar, updateLocation, updateNarrative, updateChoices, updateInventory) that each refresh one piece of the HTML UI. By studying it you'll learn how to mix p5.js canvas drawing with regular DOM manipulation via select() and createButton(), how a switch statement can drive completely different procedural art per game state, and how to structure a simple finite-state game using plain objects and arrays instead of a real API.

⚙️ How It Works

  1. When the page loads, setup() creates a canvas sized relative to the window, grabs references to five HTML elements (health bar, location text, narrative text, choices container, inventory list) with select(), and calls callOpenAI('Start fantasy adventure', null) to kick off the story.
  2. callOpenAI() searches the mockAdventureStates array for an entry whose promptMatch text is contained in the prompt or the clicked choice text - this is the 'AI' response, simulated entirely with string matching instead of a real network call.
  3. Whatever narrative, setting, health change, and item that matched response triggers updateGameState(), which pushes the narrative into storyHistory, updates gameState.location and gameState.health (clamped 0-100), and adds any new item to the inventory array.
  4. updateUI() then refreshes every DOM panel, and updateChoices() removes the old choice buttons and creates new createButton() elements for the next set of options, each wired to handleChoice() via mousePressed().
  5. Every single frame, draw() calls drawScene(gameState.location), and a switch statement inside picks a different procedural scene - forest, cave, castle, or plain - drawing shapes with random() so the scene subtly shifts every frame.
  6. Clicking a choice button calls handleChoice(), which builds a new prompt string and calls callOpenAI() again, looping the whole cycle: lookup response, update state, redraw UI, offer new choices.

🎓 Concepts You'll Learn

Finite state game logic with a plain object (gameState)Switch statement driving procedural drawing per sceneDOM manipulation with select() and createButton()Procedural art with random(), triangle(), bezierVertex()Event-driven UI with mousePressed() callbacksBrowser SpeechSynthesis API for narrationSimple string-based 'AI' simulation via array lookup

📝 Code Breakdown

getApiKey()

This function isn't really used to call OpenAI in this sketch (it's a simulation), but it demonstrates a lightweight obfuscation pattern: base64 encoding plus an XOR cipher. It's worth knowing this offers no real security since anyone can read the JavaScript and reverse the XOR.

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 return atob(encoded).split('').map(c => String.fromCharCode(c.charCodeAt(0) ^ key)).join('');

Base64-decodes the encoded string, then XORs every character code with a fixed key to reverse a simple obfuscation.

atob(encoded)
Decodes the base64-encoded string stored in the global 'encoded' constant back into raw obfuscated text.
.split('')
Breaks the decoded string into an array of individual characters so each one can be transformed.
.map(c => String.fromCharCode(c.charCodeAt(0) ^ key))
For every character, gets its numeric character code, flips bits with XOR against the global 'key' (0x5A), and turns the result back into a character - this undoes the simple XOR cipher used to hide the key.
.join('')
Glues the transformed characters back into a single string, producing the final decoded API key.

setup()

setup() runs once when the page loads. Here it does double duty: preparing the p5.js canvas AND wiring up references to plain HTML elements, showing how p5.js sketches can coexist with regular DOM UI.

function setup() {
  // Create canvas in the center of its container
  let canvasContainer = select('#canvas-container');
  // Set canvas size based on window dimensions, but constrained for better UI fit
  let canvas = createCanvas(min(windowWidth * 0.7, 800), min(windowHeight * 0.5, 600));
  canvas.parent('canvas-container');

  // Get UI DOM elements using p5's select function
  healthBarDiv = select('#health-bar');
  locationDiv = select('#location-text');
  narrativeDiv = select('#narrative-text');
  choicesDiv = select('#choices-container');
  inventoryDiv = select('#inventory-list');

  // Initial UI update to show default game state
  updateUI();

  // Start the adventure by simulating an OpenAI call for the opening narrative
  callOpenAI('Start fantasy adventure', null);
}
Line-by-line explanation (6 lines)
let canvasContainer = select('#canvas-container');
Grabs a reference to the HTML div with id 'canvas-container' using p5's select() - a wrapper around document.querySelector.
let canvas = createCanvas(min(windowWidth * 0.7, 800), min(windowHeight * 0.5, 600));
Creates the drawing canvas, sizing it to 70% of window width (capped at 800px) and 50% of window height (capped at 600px), so it scales with the browser window but never gets huge.
canvas.parent('canvas-container');
Moves the newly created canvas element inside the #canvas-container div in the HTML, instead of leaving it at the bottom of the page body.
healthBarDiv = select('#health-bar');
Stores a reference to the health bar div in a global variable so other functions can update its text and color later.
updateUI();
Runs an initial pass over all UI-updating functions so the page shows the default health/location/inventory before any story has loaded.
callOpenAI('Start fantasy adventure', null);
Kicks off the adventure by simulating the first 'AI' call, which looks up the opening narrative and choices in mockAdventureStates.

draw()

draw() is p5.js's continuous animation loop. Because drawScene() uses random() internally and is called every frame, the scenery subtly re-randomizes constantly rather than staying still - an easy trap to fall into when mixing random() with draw().

function draw() {
  // Continuously draw the scene based on the current game location
  drawScene(gameState.location);
}
Line-by-line explanation (1 lines)
drawScene(gameState.location);
Every frame (about 60 times per second), this reads the player's current location from gameState and asks drawScene() to paint the matching background art.

drawScene(setting)

drawScene() is the heart of the sketch's procedural art - a single switch statement swaps out entirely different drawing recipes depending on a string. It's a great example of how far you can get visually with just rect(), triangle(), ellipse() and beginShape()/bezierVertex(), no images required.

🔬 This loop draws 5 random trees every single frame, so the forest 'flickers' as new random values are picked 60 times a second. What happens if you raise 5 to 30? What if you also increase the range in random(80, 150) for taller trunks?

      for (let i = 0; i < 5; i++) {
        let x = random(width);
        let y = random(height * 0.6, height);
        rect(x, y, 20, random(80, 150));
        fill(34, 139, 34); // Dark green for tree leaves
        ellipse(x, y - random(40, 70), random(80, 120), random(80, 120));
      }

🔬 This loop spaces 10 battlement teeth evenly across the castle wall using width / 10 as the step. What happens if you change 10 to 20 in both the loop count and the divisor - do the teeth get thinner and more numerous, or does something break?

      for (let i = 0; i < 10; i++) {
        rect(i * (width / 10), height * 0.4, width / 20, height * 0.05);
      }
function drawScene(setting) {
  switch (setting) {
    case 'forest':
      background(100, 150, 100); // Green forest background
      noStroke();
      fill(139, 69, 19); // Brown for tree trunks
      rectMode(CENTER);
      for (let i = 0; i < 5; i++) {
        let x = random(width);
        let y = random(height * 0.6, height);
        rect(x, y, 20, random(80, 150));
        fill(34, 139, 34); // Dark green for tree leaves
        ellipse(x, y - random(40, 70), random(80, 120), random(80, 120));
      }
      break;
    case 'cave':
      background(50, 50, 50); // Dark grey cave background
      noStroke();
      fill(80, 80, 80); // Lighter grey for rocks/stalactites
      for (let i = 0; i < 7; i++) {
        let x = random(width);
        let len = random(50, 100);
        let widthTop = random(20, 40);
        // Stalactite
        triangle(x - widthTop / 2, 0, x + widthTop / 2, 0, x, len);
        // Stalagmite
        let widthBottom = random(20, 40);
        triangle(x - widthBottom / 2, height, x + widthBottom / 2, height, x, height - len);
      }
      break;
    case 'castle':
      background(150, 150, 150); // Grey castle background
      fill(120, 120, 120);
      rectMode(CORNER);
      rect(0, height * 0.4, width, height * 0.6); // Main wall
      rect(width * 0.1, height * 0.2, width * 0.15, height * 0.8); // Left tower
      rect(width * 0.75, height * 0.2, width * 0.15, height * 0.8); // Right tower
      fill(100, 100, 100);
      rect(width * 0.1, height * 0.15, width * 0.15, height * 0.05); // Tower tops
      rect(width * 0.75, height * 0.15, width * 0.15, height * 0.05);
      // Draw battlements
      for (let i = 0; i < 10; i++) {
        rect(i * (width / 10), height * 0.4, width / 20, height * 0.05);
      }
      break;
    case 'plain':
      background(150, 200, 150); // Light green for plains
      // Draw some distant hills
      fill(120, 170, 120);
      noStroke();
      beginShape();
      vertex(0, height * 0.6);
      bezierVertex(width * 0.2, height * 0.5, width * 0.3, height * 0.7, width * 0.5, height * 0.6);
      bezierVertex(width * 0.7, height * 0.5, width * 0.8, height * 0.7, width, height * 0.6);
      vertex(width, height);
      vertex(0, height);
      endShape(CLOSE);
      break;
    default:
      background(0);
      fill(255);
      textAlign(CENTER, CENTER);
      textSize(24);
      text('Loading Adventure...', width / 2, height / 2);
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

switch-case Forest Scene case 'forest':

Draws a green background with randomly placed tree trunks (rects) and leaf blobs (ellipses).

switch-case Cave Scene case 'cave':

Draws a dark background with rows of triangular stalactites hanging from the top and stalagmites rising from the floor.

switch-case Castle Scene case 'castle':

Draws grey stone walls, two side towers, and a row of small rectangle battlements along the top.

switch-case Plain Scene case 'plain':

Draws rolling hills using beginShape()/bezierVertex() to create smooth curved terrain.

switch-case Loading Fallback default:

Shows a plain black screen with 'Loading Adventure...' text if the location doesn't match any known setting.

switch (setting) {
Chooses which block of drawing code to run based on the current gameState.location string ('forest', 'cave', 'castle', 'plain', or anything else).
for (let i = 0; i < 5; i++) {
Loops 5 times to place 5 separate trees, each at a new random x/y position.
rect(x, y, 20, random(80, 150));
Draws a brown rectangle as a tree trunk with a fixed width of 20 but a random height between 80 and 150 pixels.
triangle(x - widthTop / 2, 0, x + widthTop / 2, 0, x, len);
Draws a stalactite as a triangle hanging from the top of the canvas (y=0) down to a random length.
rect(0, height * 0.4, width, height * 0.6);
Draws the castle's main wall as a rectangle spanning the full canvas width, starting 40% down and covering the bottom 60% of the height.
beginShape();
Starts a custom multi-point shape for the rolling hills, later closed with endShape(CLOSE).
bezierVertex(width * 0.2, height * 0.5, width * 0.3, height * 0.7, width * 0.5, height * 0.6);
Adds a smooth curved segment to the hill shape using two control points and an anchor point, creating a natural rolling curve instead of a straight line.
text('Loading Adventure...', width / 2, height / 2);
Displays fallback text centered on the canvas if the location string doesn't match any known case.

updateGameState(narrative, setting, healthChange, newItem)

This function is the single place where the game's core state (health, location, inventory, history) actually changes. Centralizing state mutations like this makes a game much easier to debug than scattering updates everywhere.

function updateGameState(narrative, setting, healthChange, newItem) {
  if (narrative) {
    gameState.storyHistory.push(narrative);
  }
  if (setting) {
    gameState.location = setting;
  }
  if (healthChange) {
    gameState.health += healthChange;
    gameState.health = constrain(gameState.health, 0, 100); // Keep health within 0-100 bounds
  }
  if (newItem) {
    // Handle multiple items if comma-separated
    let items = newItem.split(',').map(s => s.trim());
    items.forEach(item => {
      if (item && !gameState.inventory.includes(item)) {
        gameState.inventory.push(item);
      }
    });
  }
  updateUI(); // Refresh all UI elements
  speakNarrative(narrative); // Use speech synthesis for new narrative
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Health Change & Clamp gameState.health = constrain(gameState.health, 0, 100); // Keep health within 0-100 bounds

Applies the health change from the story event, then locks the result between 0 and 100 so health never goes negative or above full.

for-loop Multi-Item Parsing items.forEach(item => {

Splits a comma-separated newItem string (like 'Silver Key, Gold Coins') into individual items and adds each one to the inventory only if it isn't already there.

if (narrative) { gameState.storyHistory.push(narrative); }
If a new narrative string was provided, adds it to the end of storyHistory, keeping a full log of everything that's happened.
gameState.health = constrain(gameState.health, 0, 100);
constrain() clamps a number between a min and max - here it stops health from dropping below 0 or exceeding 100 after adding healthChange.
let items = newItem.split(',').map(s => s.trim());
Splits a string like 'Silver Key, Gold Coins' on commas into an array, then trims whitespace from each piece so 'Gold Coins' doesn't keep a leading space.
if (item && !gameState.inventory.includes(item)) {
Only adds the item if it's a non-empty string and isn't already in the inventory array, preventing duplicate entries.
speakNarrative(narrative);
Passes the new narrative text to the speech synthesis function so the browser can read it aloud.

updateUI()

updateUI() is a simple 'refresh everything' wrapper. Grouping related update calls into one function like this is a common pattern for keeping a UI in sync with a central state object.

function updateUI() {
  updateHealthBar();
  updateLocation();
  updateNarrative();
  updateChoices(); // Choices are passed directly by callOpenAI, so this will be called separately after getting response
  updateInventory();
}
Line-by-line explanation (2 lines)
updateHealthBar();
Refreshes the health bar's width, color and text to match the current gameState.health.
updateChoices(); // Choices are passed directly by callOpenAI, so this will be called separately after getting response
Calls updateChoices() with no arguments, which defaults to an empty array - this briefly clears choice buttons and shows a 'Start Over' button until callOpenAI() calls updateChoices(response.choices) again with the real options.

updateHealthBar()

This function shows how p5.js's .style() and .html() methods let you drive plain CSS/HTML directly from JavaScript logic, bridging canvas-style thinking with normal web UI.

function updateHealthBar() {
  let healthPercentage = gameState.health;
  healthBarDiv.html(`Health: ${healthPercentage}%`);
  // Color transitions from green to yellow to red
  if (healthPercentage > 70) {
    healthBarDiv.style('background-color', '#4CAF50'); // Green
    healthBarDiv.style('color', 'black');
  } else if (healthPercentage > 30) {
    healthBarDiv.style('background-color', '#FFC107'); // Yellow
    healthBarDiv.style('color', 'black');
  } else {
    healthBarDiv.style('background-color', '#DC3545'); // Red
    healthBarDiv.style('color', 'white');
  }
  healthBarDiv.style('width', `${healthPercentage}%`);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Health Color Thresholds if (healthPercentage > 70) { ... } else if (healthPercentage > 30) { ... } else { ... }

Picks green, yellow, or red for the health bar depending on how much health remains, giving an at-a-glance danger indicator.

healthBarDiv.html(`Health: ${healthPercentage}%`);
Uses p5's .html() method to replace the div's inner text with the current health percentage, using a template literal to insert the number.
if (healthPercentage > 70) {
If health is above 70%, the bar turns green - a safe zone.
} else if (healthPercentage > 30) {
If health is between 31% and 70%, the bar turns yellow as a caution warning.
healthBarDiv.style('width', `${healthPercentage}%`);
Sets the CSS width of the health bar div directly to the health percentage, so the bar visually shrinks as health drops.

updateLocation()

A tiny single-purpose function - a good reminder that not every UI update needs to be complicated to be useful.

function updateLocation() {
  locationDiv.html(`Location: ${gameState.location}`);
}
Line-by-line explanation (1 lines)
locationDiv.html(`Location: ${gameState.location}`);
Writes the current location name into the location text div, so the player always sees where they currently are.

updateNarrative()

This demonstrates a common JavaScript fallback pattern using the || operator to provide a default value when a variable might be empty or undefined.

function updateNarrative() {
  narrativeDiv.html(gameState.storyHistory[gameState.storyHistory.length - 1] || 'Welcome to the adventure!');
}
Line-by-line explanation (2 lines)
gameState.storyHistory[gameState.storyHistory.length - 1]
Accesses the very last item pushed into the storyHistory array - i.e. the most recent narrative text.
|| 'Welcome to the adventure!'
If storyHistory is empty (undefined at index -1), falls back to a friendly welcome message instead of showing 'undefined'.

updateChoices(choices)

This function shows the classic 'clear and rebuild' pattern for dynamic UI lists: remove all old elements, then create fresh ones from the current data. It's simple, but works well for small numbers of elements like a handful of choice buttons.

🔬 This loop turns each choice object into a real clickable button. What would happen if you also set btn.style('font-weight', 'bold') inside the loop right after btn.class(...)?

  if (choices.length > 0) {
    for (let choice of choices) {
      let btn = createButton(choice.text);
      btn.class('choice-button'); // Apply CSS class
      btn.parent(choicesDiv); // Add to choices container
      btn.mousePressed(() => handleChoice(choice.id, choice.text)); // Attach event listener
      choiceButtons.push(btn);
    }
function updateChoices(choices = []) {
  // Remove any existing choice buttons
  for (let btn of choiceButtons) {
    btn.remove();
  }
  choiceButtons = [];

  // Add new buttons based on the 'choices' array received
  if (choices.length > 0) {
    for (let choice of choices) {
      let btn = createButton(choice.text);
      btn.class('choice-button'); // Apply CSS class
      btn.parent(choicesDiv); // Add to choices container
      btn.mousePressed(() => handleChoice(choice.id, choice.text)); // Attach event listener
      choiceButtons.push(btn);
    }
  } else {
    // If no choices are provided, the adventure might be over or stuck.
    // Display a "Start Over" button.
    let endBtn = createButton('Start Over');
    endBtn.class('choice-button');
    endBtn.parent(choicesDiv);
    endBtn.mousePressed(resetGame);
    choiceButtons.push(endBtn);
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Clear Old Buttons for (let btn of choiceButtons) { btn.remove(); }

Removes every previously created choice button from the page before drawing the new set, preventing old buttons from piling up.

for-loop Create New Choice Buttons for (let choice of choices) {

Loops through the array of choice objects passed in and creates one clickable button per choice.

conditional No Choices Fallback if (choices.length > 0) { ... } else { ... }

If the story has no further choices (or none were passed yet), shows a single 'Start Over' button instead of a blank choices area.

function updateChoices(choices = []) {
Declares a default parameter, so calling updateChoices() with no arguments treats choices as an empty array rather than undefined.
for (let btn of choiceButtons) { btn.remove(); }
Iterates over the array of button elements created last time and calls .remove() on each, deleting them from the DOM.
let btn = createButton(choice.text);
Creates a new clickable HTML button element with the choice's text label, using p5's createButton().
btn.mousePressed(() => handleChoice(choice.id, choice.text));
Attaches a click event listener using an arrow function, so clicking this specific button calls handleChoice() with that choice's id and text.
let endBtn = createButton('Start Over');
When there are no more choices (story dead-end), creates a single reset button instead.

handleChoice(choiceId, choiceText)

This is the bridge between a user interaction (a button click) and the game's 'AI' logic - a pattern you'll reuse anytime a UI event needs to trigger a state lookup.

function handleChoice(choiceId, choiceText) {
  // Construct the prompt for the OpenAI call (simulated)
  let prompt = `Player chose:${choiceText}`;
  callOpenAI(prompt, choiceText); // Call the simulated OpenAI function
}
Line-by-line explanation (2 lines)
let prompt = `Player chose:${choiceText}`;
Builds a prompt string that would normally be sent to a real AI, embedding the exact text of the button the player clicked.
callOpenAI(prompt, choiceText);
Passes both the full prompt and the raw choice text to callOpenAI(), which will search mockAdventureStates for a matching entry.

updateInventory()

Array.join() is a handy way to turn a list of collected items into readable display text without manually writing a loop.

function updateInventory() {
  if (gameState.inventory.length > 0) {
    inventoryDiv.html('Inventory: ' + gameState.inventory.join(', '));
  } else {
    inventoryDiv.html('Inventory: Empty');
  }
}
Line-by-line explanation (1 lines)

🔧 Subcomponents:

conditional Empty Inventory Check if (gameState.inventory.length > 0) { ... } else { ... }

Shows a comma-separated list of items if the player has collected any, otherwise shows 'Empty'.

gameState.inventory.join(', ')
Turns the inventory array (e.g. ['Silver Key', 'Gold Coins']) into a single readable string separated by commas.

speakNarrative(text)

This function taps directly into the browser's built-in Web Speech API rather than p5.sound, showing that creative coding sketches can freely combine p5.js with any native browser API.

function speakNarrative(text) {
  if ('speechSynthesis' in window) {
    let utterance = new SpeechSynthesisUtterance(text);
    utterance.lang = 'en-US';
    utterance.rate = 1.0; // Normal speed
    utterance.pitch = 1.0; // Normal pitch
    speechSynthesis.speak(utterance);
  } else {
    console.warn('Speech synthesis not supported in this browser.');
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Feature Detection if ('speechSynthesis' in window) { ... } else { ... }

Checks whether the browser supports the SpeechSynthesis API before trying to use it, avoiding a crash in unsupported browsers.

if ('speechSynthesis' in window) {
Feature-detects whether the browser's global window object has speech synthesis support before using it.
let utterance = new SpeechSynthesisUtterance(text);
Creates a speech request object wrapping the narrative text that will be spoken aloud.
utterance.rate = 1.0; // Normal speed
Sets how fast the voice speaks - 1.0 is normal, lower is slower, higher is faster.
speechSynthesis.speak(utterance);
Sends the utterance to the browser's built-in text-to-speech engine, which reads it aloud using the device's voice.

resetGame()

This is the button handler wired up in updateChoices() whenever the story runs out of choices - a simple 'new game' pattern that just replaces the whole state object.

function resetGame() {
  gameState = { health: 100, inventory: [], location: 'Unknown', storyHistory: [] };
  updateUI();
  callOpenAI('Start fantasy adventure', null); // Restart the adventure
}
Line-by-line explanation (2 lines)
gameState = { health: 100, inventory: [], location: 'Unknown', storyHistory: [] };
Replaces the entire gameState object with a fresh copy at default values, wiping health, inventory, location and story history.
callOpenAI('Start fantasy adventure', null);
Restarts the story from the very beginning by simulating the opening 'AI' call again.

callOpenAI(prompt, choiceText)

callOpenAI() is the 'brain' of this sketch - it's named after a real API call but is entirely simulated with array searching and string matching. Studying it shows how you can prototype an AI-driven interaction entirely offline before ever wiring up a real API.

🔬 This loop stops at the FIRST matching promptMatch it finds using .includes(), not an exact match. What do you think happens if two entries in mockAdventureStates have overlapping promptMatch text, like 'Enter the cave' and 'Enter the cave with the retinue'? Which one wins?

  for (let state of mockAdventureStates) {
    if (prompt.includes(state.promptMatch) || (choiceText && choiceText.includes(state.promptMatch))) {
      response = state.response;
      break;
    }
  }
function callOpenAI(prompt, choiceText) {
  console.log('Simulating OpenAI call with prompt:', prompt);

  let response = null;
  // Iterate through mock states to find a matching prompt or choice text
  for (let state of mockAdventureStates) {
    if (prompt.includes(state.promptMatch) || (choiceText && choiceText.includes(state.promptMatch))) {
      response = state.response;
      break;
    }
  }

  if (response) {
    // If a match is found, update the game state and choices
    updateGameState(
      response.narrative,
      response.setting,
      response.healthChange,
      response.newItem
    );
    updateChoices(response.choices);
  } else {
    // Fallback for unmatched prompts: provide a generic message and options
    updateGameState(
      "The world seems to be in a state of confusion. Perhaps you should try a different path.",
      gameState.location,
      -5, // Minor health penalty for getting lost
      null
    );
    updateChoices([
      { id: 1, text: 'Return to the forest path' },
      { id: 9, text: 'Look for a track north' }
    ]);
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Match Found vs Fallback if (response) { ... } else { ... }

If a matching story state was found, applies its narrative/setting/health/item and choices; otherwise shows a generic 'lost' message with a small health penalty.

for (let state of mockAdventureStates) {
Loops through every pre-written story entry in the big mockAdventureStates array, checking each one in order.
if (prompt.includes(state.promptMatch) || (choiceText && choiceText.includes(state.promptMatch))) {
Checks whether the current prompt string, or the clicked choice's text, contains this entry's promptMatch keyword - this is how the 'AI' fakes understanding player intent.
response = state.response;
Once a match is found, saves that entry's whole response object (narrative, setting, choices, healthChange, newItem).
break;
Stops searching further once the first match is found, since only one response is needed.
updateGameState(response.narrative, response.setting, response.healthChange, response.newItem);
Applies the matched story event's effects to the game state (new narrative text, location, health, item).
updateChoices(response.choices);
Creates the next set of choice buttons based on what this story entry offers the player.

windowResized()

windowResized() is a special p5.js function that automatically runs whenever the browser window is resized, letting you make responsive sketches. This particular implementation has a scoping bug worth fixing (see improvements).

function windowResized() {
  let canvasContainer = select('#canvas-container');
  resizeCanvas(min(windowWidth * 0.7, 800), min(windowHeight * 0.5, 600));
  canvasContainer.style('width', canvas.width + 'px');
  canvasContainer.style('height', canvas.height + 'px');
}
Line-by-line explanation (2 lines)
resizeCanvas(min(windowWidth * 0.7, 800), min(windowHeight * 0.5, 600));
Recomputes the canvas size using the same formula as setup(), so the canvas stays responsive whenever the browser window changes size.
canvasContainer.style('width', canvas.width + 'px');
Intended to sync the container div's CSS width to the new canvas size, but 'canvas' here refers to a variable that was only ever declared locally inside setup() - see the improvements section for why this line actually throws an error.

📦 Key Variables

gameState object

The single source of truth for the game: current health, inventory array, location string, and the full storyHistory array of past narrative text.

let gameState = { health: 100, inventory: [], location: 'Unknown', storyHistory: [] };
healthBarDiv object

p5.Element reference to the #health-bar div, used to update its text, width, and color each time health changes.

let healthBarDiv;
locationDiv object

p5.Element reference to the #location-text div, updated whenever gameState.location changes.

let locationDiv;
narrativeDiv object

p5.Element reference to the #narrative-text paragraph, showing the latest story text.

let narrativeDiv;
choicesDiv object

p5.Element reference to the #choices-container div that holds the dynamically created choice buttons.

let choicesDiv;
inventoryDiv object

p5.Element reference to the #inventory-list paragraph, showing collected items.

let inventoryDiv;
choiceButtons array

Keeps track of the currently displayed choice button elements so they can all be removed before the next set is created.

let choiceButtons = [];
encoded string

A base64-encoded, XOR-obfuscated string that getApiKey() can decode back into an API key (unused for real network calls in this simulation).

const encoded = 'KTF3Kig1MHcIaDhqCms+OA0A...';
key number

The XOR key (0x5A) used to obfuscate/deobfuscate the encoded API key string.

const key = 0x5A;
mockAdventureStates array

A large array of story-state objects, each with a promptMatch string and a response object (narrative, setting, choices, healthChange, newItem) - this array acts as the entire fake 'AI', simulating what a real language model would generate.

const mockAdventureStates = [ { promptMatch: 'Start fantasy adventure', response: { narrative: '...', setting: 'forest', choices: [...] } } ];

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG windowResized()

windowResized() references a variable named 'canvas', but 'canvas' was only ever declared with 'let canvas' inside setup() - it doesn't exist in windowResized()'s scope. Resizing the browser window will throw a ReferenceError in the console and break the responsive resize.

💡 Make the canvas reference global (e.g. 'let canvasElement;' at the top, assign it in setup() as 'canvasElement = createCanvas(...)', then use 'canvasElement.width' in windowResized()), or simply use the built-in global 'width' and 'height' variables instead of 'canvas.width'/'canvas.height'.

PERFORMANCE drawScene()

Because draw() calls drawScene() every frame and drawScene() uses random() to place trees, stalactites, etc., the entire scene re-randomizes 60 times per second, causing constant flickering instead of a stable picture.

💡 Generate the scene's random elements once whenever gameState.location changes (e.g. store an array of tree/rock positions), and have drawScene() simply redraw that stored array each frame instead of calling random() every frame.

BUG updateUI() / updateChoices()

updateUI() calls updateChoices() with no arguments, which defaults 'choices' to an empty array. This briefly replaces the current choice buttons with a 'Start Over' button every time updateUI() runs, right before callOpenAI() calls updateChoices(response.choices) again with the real options - causing a visible flash of the wrong button.

💡 Remove the bare updateChoices() call from updateUI() (as the code comment already hints) and only call updateChoices(response.choices) from callOpenAI() after the real choices are known.

STYLE setup() and windowResized()

Both functions declare 'let canvasContainer = select("#canvas-container");' but never actually use that variable for anything.

💡 Remove the unused canvasContainer declarations, or use them meaningfully (e.g. to apply a CSS class), to keep the code clean and avoid confusion about what the variable is for.

🔄 Code Flow

Code flow showing getapikey, setup, draw, drawscene, updategamestate, updateui, updatehealthbar, updatelocation, updatenarrative, updatechoices, handlechoice, updateinventory, speaknarrative, resetgame, callopenai, windowresized

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> drawscene[drawScene] drawscene --> xor-decode[xor-decode] drawscene --> forest-case[forest-case] drawscene --> cave-case[cave-case] drawscene --> castle-case[castle-case] drawscene --> plain-case[plain-case] drawscene --> default-case[default-case] draw --> updateui[updateUI] updateui --> updatehealthbar[updateHealthBar] updateui --> updatelocation[updateLocation] updateui --> updatenarrative[updateNarrative] updateui --> updatechoices[updateChoices] updatechoices --> remove-old-buttons[remove-old-buttons] remove-old-buttons --> create-new-buttons[create-new-buttons] create-new-buttons --> no-choices-fallback[no-choices-fallback] updateui --> updateinventory[updateInventory] updateinventory --> inventory-check[inventory-check] updateui --> handlechoice[handleChoice] handlechoice --> callopenai[callOpenAI] callopenai --> state-search[state-search] state-search --> response-branch[response-branch] response-branch --> updategamestate[updateGameState] updategamestate --> health-clamp[health-clamp] updategamestate --> updateui updateui --> speaknarrative[speakNarrative] speaknarrative --> browser-support-check[browser-support-check] draw --> windowresized[windowResized] click setup href "#fn-setup" click draw href "#fn-draw" click drawscene href "#fn-drawscene" click updateui href "#fn-updateui" click updategamestate href "#fn-updategamestate" click updatehealthbar href "#fn-updatehealthbar" click updatelocation href "#fn-updatelocation" click updatenarrative href "#fn-updatenarrative" click updatechoices href "#fn-updatechoices" click handlechoice href "#fn-handlechoice" click updateinventory href "#fn-updateinventory" click speaknarrative href "#fn-speaknarrative" click callopenai href "#fn-callopenai" click windowresized href "#fn-windowresized" click xor-decode href "#sub-xor-decode" click forest-case href "#sub-forest-case" click cave-case href "#sub-cave-case" click castle-case href "#sub-castle-case" click plain-case href "#sub-plain-case" click default-case href "#sub-default-case" click health-clamp href "#sub-health-clamp" click item-parse href "#sub-item-parse" click health-color-branch href "#sub-health-color-branch" click remove-old-buttons href "#sub-remove-old-buttons" click create-new-buttons href "#sub-create-new-buttons" click no-choices-fallback href "#sub-no-choices-fallback" click inventory-check href "#sub-inventory-check" click browser-support-check href "#sub-browser-support-check" click state-search href "#sub-state-search" click response-branch href "#sub-response-branch"

❓ Frequently Asked Questions

What visual experience does the AI Quest Master v2 sketch offer?

The sketch visually represents a fantasy RPG setting by procedurally drawing scenes based on AI-generated narratives, immersing players in a dynamic environment.

How can players interact with the AI Quest Master v2 adventure?

Players interact by making choices presented in the form of buttons, which shape the narrative and affect the game's outcome.

What creative coding concepts are showcased in this p5.js sketch?

The sketch demonstrates procedural generation and AI integration, allowing for an evolving storyline and personalized gameplay based on user decisions.

Preview

AI Quest Master v2 - Fantasy RPG - xelsed.ai - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Quest Master v2 - Fantasy RPG - xelsed.ai - Code flow showing getapikey, setup, draw, drawscene, updategamestate, updateui, updatehealthbar, updatelocation, updatenarrative, updatechoices, handlechoice, updateinventory, speaknarrative, resetgame, callopenai, windowresized
Code Flow Diagram