Concept
I was very interested in using non-conventional inputs for RPG based games, and had initially planned to make an audio-based movement game. However, I came to the conclusion that the lag between the player voicing out the command and it executing in game would cause a loss of interest. This is when I decided to go into using a hybrid strategy where the user still controls the player using keys, but unlocks special abilities when humming.
I incorporated the idea of short but tricky levels from Level Devil, a simple 2D level based RPG game, so a player would typically try multiple times per level, making the player feel smarter at the end of each level. Here are a few key decisions I took to give my project direction:
-
- Level 3 should be unusually harder and the newfound ability doesn’t help, the player will get stuck, and they must learn to master the key controls to maneuver the player between the ladders. This accomplishment will make the next levels much easier, and make players more likely to finish the game.
- The game starts after enter is pressed from the start scene, the player will use either WASD or up,down,left,right arrows to move. The movement must be smooth and the player walks should feel natural.
- Sprites increase computing and reduce FPS, making the game slow, so I chose to create the character using code.
- I kept the death count and level count as indicators of progress, and decided to name each level.
- Sound effects, and background music must be handpicked.
- Player must get the stop-time and reverse-time abilities after 2 levels and abilities must grow as levels progress.
- Giving player and theme customisability (a suggestion from a classmate)
Sketch
Implementation details – Game States
The global state variable (managed in js/state.js) determines which update cycle and rendering pipeline draw() executes each frame:
title → tutorialControls → play ↔ dead / won (level 1,2)→ tutorialAudio → play ↔ dead / won (level 3-12) → … → ending
Every frame, draw() in js/sketch.js performs the following steps:
-
- Calculates a clamped delta/marginal time (
dt). - Executes the update logic corresponding to the active state.
- Applies uniform aspect ratio scaling and letterboxing transforms (
translate(0, view.oy); scale(view.s);). - Calls the appropriate drawing functions for world entities, traps, and UI overlays.
- Calculates a clamped delta/marginal time (
Script Load Order
Load order is critical for initialization-time dependencies (for instance, state.js relies on constants defined in levels.js). sketch.js must always be loaded last as the main entry point.
| # | Script File | Responsibility & Core Systems |
|---|---|---|
| 1 | levels.js |
Constants (W, H, physics parameters, stamina limits, skin presets, color palette) and the 12 level layout definitions via buildLevel(i). |
| 2 | audio.js |
Sound effects, background music streams, microphone/FFT frequency analysis, hum calibration, and time-ability duration timers. |
| 3 | state.js |
Shared mutable game state (level, player, active state, particle systems, skin/theme preferences, camera view dimensions, win animation variables). |
| 4 | stamina.js |
Player stamina consumption/regeneration, sprint inertia carry-over, skin styling, and camera viewport follow updates. |
| 5 | storage.js |
Safe localStorage wrapper (GameStorage) with graceful fallbacks for private browsing / restricted contexts. |
| 6 | input.js |
Native capture-phase key tracking via activeKeys, keyboard focus grabbing on canvas click, and multi-key helper functions. |
| 7 | voice-powers.js |
Hum-activated time manipulation: Freeze (stops world simulation) and Rewind (rewinds environment while leaving player freely movable). |
| 8 | physics.js |
Moving platforms, ladder climbing mechanics, platform collisions, footstep triggers, and crumbling tile timers. |
| 9 | game-update.js |
Main gameplay per-frame loop (updateGame), gate relocations, exit switches, and collision query utilities (isSolid). |
| 10 | traps.js |
Trap trigger detection and movement mechanics (chase wall, pop spikes, falling spikes, spinning saws, lasers, springboards, fake platforms). |
| 11 | game-flow.js |
Geometry intersection checks (AABB, circle-rect), death handling, level completion sequences, win animations, and particle emissions. |
| 12 | time-overlays.js |
Post-processing visual overlays and HUD indicators for active Time Freeze and Time Rewind. |
| 13 | scene.js |
Top-level scene composition orchestrator (drawScene). |
| 14 | title.js |
Title screen rendering, theme & skin selection selectors, and game start transitions. |
| 15 | tutorial.js |
Controls onboarding screen, ability selection menu, and voice frequency calibration module. |
| 16 | ending.js |
Ending cutscene sequence, stats overview, and victory presentation. |
| 17 | backdrop.js |
Background rendering, artwork asset loading, and procedural fallbacks for theme backgrounds. |
| 18 | world-render.js |
Procedural geometry drawing for platforms, doors, ladders, character speech banners, and programmatic player animation. |
| 19 | traps-render.js |
Individual trap renderers and dispatchers (spikes, saws, laser beams, crumbling stones). |
| 20 | ui.js |
In-game HUD (death tally, level titles, controls banner), settings slide-out panel, and UI button click areas. |
| 21 | events.js |
p5 lifecycle event dispatchers (mousePressed, touchStarted, keyPressed, keyReleased). |
| 22 | sketch.js |
Entry point (setup, draw, windowResized), p5 accessibility bug monkey-patches, letterbox scaling, and level resetting. |
Assets Used
(All audio used was found from Pixabay and trimmed to fit the game sound needed)
-
- Background Music (
background-music.mp3): Ambient soundtrack looping during gameplay. - Good / Success (
good.mp3): Level clear chime played upon reaching the exit gate. - Glass Breaking (
glass-breaking.mp3): Death and impact sound effect when hitting traps. - Ladder (
ladder.mp3): Climbing audio triggered when ascending/descending ladders. - New Message (
new-msg.mp3): Dialogue and narrative notification pop sound.
- Background Music (
-
- Hell Theme (
hell.png): Background artwork for the hell/underworld theme. - Nature Theme (
nature.png): Unlockable background artwork for the nature theme. - Settings Gear (
gear.svg): Vector icon for the in-game settings panel button.
- Hell Theme (
Challenges Faced
-
- Key inputs were a major challenge on the web editor. Often the preview canvas doesn’t initially receive keyboard focus. In the canvas, clicking the preview didn’t focus it, so keyboard events went to the editor instead.
- Refactoring sketch.js wasn’t easy either, maintaining SOLID principles got harder as more features were added.
- Humming conflicting with background noise, to combat this, I chose to simply lower the volume while recording the hum and keeping it lower in the next levels.
Code that I am Proud Of
Detecting pitch in real-time in the browser can be noisy. Rather than relying on heavy third-party speech libraries, I wrote a lightweight FFT peak-detection algorithm that isolates the dominant fundamental frequency around the player’s calibrated humming range while filtering out background noise:
// Detects the dominant pitch frequency from FFT spectrum data
function detectPitch(spectrum) {
let maxEnergy = 0;
let peakBin = -1;
let nyquist = sampleRate() / 2;
let binWidth = nyquist / spectrum.length;
// Scan only musical vocal range (80 Hz to 600 Hz)
let minBin = Math.floor(80 / binWidth);
let maxBin = Math.min(Math.floor(600 / binWidth), spectrum.length - 1);
for (let i = minBin; i <= maxBin; i++) { if (spectrum[i] > maxEnergy) {
maxEnergy = spectrum[i];
peakBin = i;
}
}
// Return the detected frequency if it passes our confidence threshold
return (maxEnergy > 140 && peakBin !== -1) ? peakBin * binWidth : 0;
}
Why I’m proud of this: It runs at 60 FPS directly within the p5 audio loop with zero noticeable latency, allowing the voice abilities to feel instant and responsive.
When rewinding time, standard mechanics often rewind the player along with everything else, trapping them in the same fatal mistake. I designed an asymmetric rewind system where the game world rolls back, but the player retains full agency to escape incoming hazards:
// Rewinds traps and level obstacles while leaving the player freely movable
function applyTimeRewind(targetFrame) {
let snapshot = timeHistory[targetFrame];
if (!snapshot) return;
// Restore environmental state (traps, moving platforms, crumbling floors)
for (let i = 0; i < snapshot.traps.length; i++) {
Object.assign(level.traps[i], snapshot.traps[i]);
}
gameTime = snapshot.gameTime;
// Noticeably absent: player.x and player.y are NOT restored.
// The player stays completely active and can dodge out of the trap's path!
}
Why I’m proud of this: It turns a frustrating trial-and-error moment into a dynamic, clutch escape mechanic that feels empowering to the player.
Reflection
I am pretty happy with the final game, I learnt a lot of new things in p5 and realized how the organization of the code into classes representing various types of objects made it much easier to understand and develop. There is some scope for improvement that could be done in the future:
-
- Making it multiplayer. (Conflicting hums would be interesting to tackle)
- Adding more sound effects and tricky situations in levels.
- Adding a level map, so players can revisit old levels