User profile picture

Building a Paper Mario Action Command System

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.

Play | Repo


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:

  1. State Machine — organizing game flow
  2. Action Command — the timing-based input system
  3. 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:

  1. The timing window shrinks — making it harder to land the next hit
  2. 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.

Tags:

# game-dev

# combat-system

# javascript