Gamify Exploration
A game design lesson on sprite sheets, chase logic, sprite swapping, and NPC interaction systems
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.