A browser-based recreation of Paper Mario’s Power Bounce combat system—the repeated jump attack where you time button presses to keep bouncing and dealing damage.
The Big Picture
The Power Bounce is a repeated jump attack where Mario bounces off enemies, dealing damage each time. The twist? You have to press a button at the right moment to maximize damage and keep bouncing. Miss the timing, and Mario falls to the ground—game over for that combo.
Three core systems make this work:
- State Machine — organizing game flow
- Action Command — the timing-based input system
- Difficulty Scaling — making it get harder as you improve
Let’s dive into each one.
1. The State Machine
At its core, a game is just a state machine—a set of “modes” the game can be in, with rules for how to transition between them. Here’s how we defined the states for Power Bounce:
const STATE = {
TITLE: 0, // Waiting for player to start
IDLE: 1, // Mario standing still, waiting for input
RUN_TO_ENEMY: 2, // Mario running toward the Goomba
JUMP_UP: 3, // Mario's initial jump arc
FALL_TO_STOMP: 4, // Mario falling toward the enemy
ACTION_WINDOW: 5, // Timing window is active (we'll cover this)
STOMP_HIT: 6, // Successful hit—deal damage, bounce up
BOUNCE_UP: 7, // Rising after a successful hit
BOUNCE_FALL: 8, // Falling for the next potential hit
MISS_BOUNCE: 9, // Weak bounce after missing timing
MISS_FALL: 10, // Falling to the ground after missing
RESULTS: 11, // Show final score
};
Each frame, the game checks state and runs different logic. For example, in JUMP_UP, we apply upward velocity to Mario. In BOUNCE_FALL, we check if the player pressed the button at the right time.
The update() function is the heart of this:
function update(dt) {
stateTimer += dt;
switch (state) {
case STATE.TITLE:
if (inputJustPressed) startGame();
break;
case STATE.IDLE:
mario.sprite = 'idle';
if (inputJustPressed) {
state = STATE.RUN_TO_ENEMY;
sfxJump();
}
break;
case STATE.RUN_TO_ENEMY: {
mario.sprite = 'run';
mario.x += runSpeed;
if (mario.x >= targetX) {
state = STATE.JUMP_UP;
jumpVelY = -12;
}
break;
}
// ... more states
}
}
This pattern—checking “what mode are we in, then do the right thing”—is how virtually every game works. It’s clean, organized, and easy to debug.
2. The Action Command System
Here’s where the skill expression comes in. As Mario falls toward the enemy, a ring shrinks around the target. You need to press your button when the ring is in the “sweet spot.”
Drawing the Ring
We track progress from 0 to 1 as the ring shrinks:
function drawActionRing(x, y) {
if (!ringActive) return;
// Ring starts at outer radius, shrinks to inner radius
const outerR = 40;
const innerR = 14;
const currentR = outerR - (outerR - innerR) * ringProgress;
// Determine color based on timing zone
// Green = perfect zone (Nice), Yellow = okay zone (Good), Red = too late
const inZone = ringProgress >= 0.70 && ringProgress <= 0.95;
const ringColor = inZone ? '#3DF53D' : (ringProgress > 0.95 ? '#FF4444' : '#FFD700');
// Draw the shrinking ring
ctx.beginPath();
ctx.arc(x, y, currentR, 0, Math.PI * 2);
ctx.strokeStyle = ringColor;
ctx.lineWidth = 4;
ctx.stroke();
// Add a glow effect when in the perfect zone
if (inZone) {
ctx.beginPath();
ctx.arc(x, y, currentR, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(61, 245, 61, 0.3)';
ctx.lineWidth = 10;
ctx.stroke();
}
}
Detecting the Input
When the player presses a button, we check where ringProgress is:
if (inputJustPressed && !actionPressed) {
actionPressed = true;
const accuracy = ringProgress;
// Determine result based on timing
// Nice: 80-95% (the green zone)
// Good: 70-100% (the yellow zone)
// Miss: anything outside that range
if (accuracy >= 0.70 && accuracy <= 1.0) {
actionResult = accuracy >= 0.80 && accuracy <= 0.95 ? 'nice' : 'good';
} else {
actionResult = 'miss';
}
}
// If ring completes without input, it's a miss
if (ringProgress >= 1 && !actionPressed) {
actionResult = 'miss';
actionPressed = true;
}
The timing windows are:
- Nice (best): 80-95% — requires precise timing
- Good (okay): 70-100% — more forgiving
- Miss (fail): outside those ranges
3. Combo & Difficulty Scaling
This is what makes the system engaging. As you build a combo, two things happen:
- The timing window shrinks — making it harder to land the next hit
- Mario bounces higher — giving you less time to react
Here’s how we calculate the timing window:
// Timing - gets tighter with each hit
const BASE_WINDOW = 320; // 320ms at the start (very generous)
const WINDOW_DECREASE = 18; // Lose 18ms per combo
const MIN_WINDOW = 75; // Never go below 75ms (human limit)
function getWindowDuration() {
return Math.max(MIN_WINDOW, BASE_WINDOW - combo * WINDOW_DECREASE);
}
So after 10 successful hits, the window is down to 140ms—requiring genuine reflexes.
The damage also scales:
function getDamage() {
if (combo === 0) return 2; // First hit always deals 2 damage
return 1; // Subsequent hits deal 1
}
And the bounce height increases:
// Bounce velocity increases with combo, capped at +3
jumpVelY = -9 - Math.min(combo * 0.3, 3);
This creates a satisfying progression: early hits are easy (build your confidence), but as you chain more together, the system demands more precision while rewarding you with higher bounces and flashier effects.
