Game Status: Not Started

Concept 1 - Message & interaction logic (GameLevelAquaticGameLevel)

Every interactable object — Mermaid, Slime, starfish, trash — exposes an interact() method on its data object. The engine calls it when the player is close and presses the action key. The method body is just a regular function, so it can branch on game state, open dialogues, spawn items, or advance quests.

flowchart TD
  A[Player presses interact near Mermaid] --> B{Quest 1 accepted?}
  B -- No --> C[Mermaid asks player to accept Aquatic Quest 1]
  C --> D[Player accepts quest]
  B -- Yes --> E[Mermaid provides Aquatic Quest 1 status]
  D --> F[Track starfish collected]
  E --> F
  F --> G{Starfish requirement met?}
  G -- No --> H[Mermaid says collect more starfishes]
  H --> F
  G -- Yes --> I[Complete Aquatic Quest 1]
  I --> J[Unlock Aquatic Quest 2 dialogue]
  J --> K[Slime NPC becomes next quest contact]
interact: function() {
  if (!this.dialogueSystem) return;

  const q1 = questState.firstQuest;
  if (q1.accepted) {
    this.dialogueSystem.showDialogue(
      'Keep collecting starfishes!', 'Mermaid', null
    );
    return;
  }
  // First interaction: show accept/decline buttons
  this.dialogueSystem.showDialogue(
    "I've lost all my starfishes. Will you collect them?",
    'Mermaid', null
  );
  this.dialogueSystem.addButtons([
    { text: 'Accept', primary: true, action: () => { q1.accepted = true; } },
    { text: 'Decline', action: () => this.dialogueSystem.closeDialogue() }
  ]);
}

Dialogue branching is driven by a plain state machine object — no class hierarchy needed. Each flag is a boolean or number that the dialogue methods read before deciding which text to show:

const questState = {
  firstQuest:  { accepted: false, completed: false, collected: 0, starfishTotal: 8 },
  secondQuest: { accepted: false, inSurface: false, completed: false, collected: 0 }
};

Collectibles like starfish use the same interact() hook but do something lighter — increment a counter, call this.destroy() to remove themselves from the scene, then update the HUD:

interact: function() {
  questState.firstQuest.collected += 1;
  updateQuestHud();
  this.destroy();          // removes canvas element + splices from gameObjects
}

A recurring problem with multi-step dialogues is stale action buttons accumulating in the DOM. The level solves this with a clearDialogueActionButtons() helper that walks the dialogue box’s child nodes and removes any flex containers that are not the avatar row:

const clearDialogueActionButtons = (dialogueSystem) => {
  const buttons = dialogueSystem.dialogueBox
    .querySelectorAll('div[style*="display: flex"]');
  buttons.forEach((el) => {
    if (!el.contains(avatarElement)) el.remove();
  });
};

Concept 2 - Character Swap Menu (Seek Level)

Press Q to open or close the character menu. The menu is a simple popup (div) with buttons for each character.

When a button is clicked, the player’s sprite changes without creating a new player.

Key idea: We update the existing player instead of replacing it.

// Press Q to toggle the menu
document.addEventListener("keydown", (e) => {
    if (e.key.toLowerCase() === "q") {
        e.preventDefault();
        toggleMenu();
    }
});

Toggling the Menu

The menu is shown or hidden by checking its current state.

const toggleMenu = () => {
    const isOpen = spriteMenu.style.display === 'block';
    setMenuVisibility(!isOpen);
};

Prevent Duplicate Menus

Before creating a menu, the game removes any existing one. This avoids multiple menus stacking on top of each other.

const existingMenu = document.getElementById(menuId);
if (existingMenu) existingMenu.remove();

Changing the Player Sprite

When a character is selected:

  • The sprite image is updated
  • The same player object is reused

The game waits for the image to load before rendering it.

player.spriteReady = false;

player.spriteSheet.onload = () => {
    player.spriteReady = true;
    player.resize();
};

player.spriteSheet.src = spriteOption.src;

Supporting Different Characters

Each character can have different:

  • Sprite layouts (rows/columns)
  • Sizes (scale)
  • Animation speeds

One system handles all of them.

const spriteOptions = [
    { label: "Boy",   orientation: { rows: 4, columns: 3  }, SCALE_FACTOR: 5,  ANIMATION_RATE: 50  },
    { label: "Kirby", orientation: { rows: 1, columns: 13 }, SCALE_FACTOR: 7,  ANIMATION_RATE: 8   },
    { label: "Astro", orientation: { rows: 4, columns: 4  }, SCALE_FACTOR: 11, ANIMATION_RATE: 110 },
];

Concept 3 - Chase Logic


Controls (Basketball Level Reference)

Key Action
WASD Move the player
E Shoot a basketball (stuns Kirby 3 s)
R Restart the round after being caught

PART 1 — Finding the Player and Enemy

Before anything can happen, the game needs to find both the player and the chaser. This runs every frame inside update().

const player = this.gameEnv.gameObjects.find(obj => obj?.spriteData?.id === 'BasketballPlayer');
const lebron = this.gameEnv.gameObjects.find(obj => obj?.spriteData?.id === 'LeBron');
if (!player || !lebron) return;
  • gameObjects = everything currently in the game
  • .find() = searches for the object with that ID
  • !player   !lebron = checks if one is missing
  • return = stops the code early so the game doesn’t crash

PART 2 - Moving Towards the Player

const dx = player.position.x - lebron.position.x;
const dy = player.position.y - lebron.position.y;
lebron.position.x += dx;
lebron.position.y += dy;

What’s Going On:

  • dx = how far left/right the player is
  • dy = how far up/down the player is
  • This creates a direction toward the player
  • The enemy moves directly using that direction
  • Problem: movement is way too fast because it depends on distance

PART 3 - Fixing Speed with Direction

const dx = player.position.x - lebron.position.x;
const dy = player.position.y - lebron.position.y;
const dist = Math.hypot(dx, dy);

lebron.position.x += (dx / dist) * speed;
lebron.position.y += (dy / dist) * speed;
  • What’s happening:

  • dx and dy point toward the player
  • dist is how far away the player is
  • Dividing by dist removes the distance
  • This leaves only direction
  • Multiplying by speed sets how fast the enemy moves

  • Key idea:

  • Divide by distance → get direction
  • Multiply by speed → control movement

PART 4 - Unique Changes

Speed Scaling

const speed = Math.min(2.1 + this.currentTime * 0.03, 2.8);
  • What’s Going On:

  • Starts at a base speed (2.1)
  • Increases over time (currentTime)
  • Math.min caps the speed so it doesn’t get too fast
  • This makes the game gradually harder but still fair

Face the Direction of Player

if (Math.abs(dx) > Math.abs(dy)) {
  lebron.direction = dx >= 0 ? 'right' : 'left';
} else {
  lebron.direction = dy >= 0 ? 'down' : 'up';
}
  • What’s Going On:

  • Compares horizontal vs vertical movement
  • Moves in the stronger direction
  • Updates sprite direction (left/right/up/down)
  • Makes movement look natural and responsive

Collision Detection

if (this.isHitboxCollision(player, lebron)) {
  this.caught = true;
  this.showCaughtMessage();
}
  • What’s Going On:

  • Checks if player and enemy overlap
  • Uses hitboxes (invisible rectangles)
  • If they touch → player is caught
  • Triggers game over / reset behavior

Final Idea

Press Q → choose a character → update sprite → keep playing instantly.

Everything works by updating the player’s properties in real time.

Hook / method When it fires Typical body
interact() Player presses action key while colliding Open dialogue or collect item
reaction() Passive collision without key press Usually a no-op ({}) to suppress default popups
destroy() Called by interact() on collectibles Removes canvas, splices from gameObjects
showDialogue(text, name, null) Inside interact() Renders dialogue box with NPC name header
addButtons([...]) After showDialogue() Appends action buttons with inline callbacks

All three concepts compose: interaction logic (Concept 1), live sprite swaps (Concept 2), and chase behavior (Concept 3). That composition is what powers the full multi-level flow.