Leave without saving?

Your editor changes will be lost.

Mario-2’s hero works, but doesn’t feel great. Mario-3 allows you to play with how responsive the controls are. Hold Shift to run, reverse at top speed to skid, tap or hold Space for short or tall jumps, and open the ⚙️ Tuning panel to change the numbers live.

The Tuning Panel

Every setting in PlayerSettings and the new CameraSettings now has a slider. main.js passes each settings object along with its definitions to the control panel:

setUpControlPanel(game.services.canvas, [
{ values: PlayerSettings, definitions: playerSettingDefinitions },
{ values: CameraSettings, definitions: cameraSettingDefinitions },
{ values: DebugSettings, definitions: debugSettingDefinitions },
]);

A definition describes one slider (or a checkbox, if it has no min/max):

{
key: "walkSpeed",
group: "🕹️ Movement",
label: "Walk Speed (tiles/s)",
min: 1,
max: 15,
step: 0.1,
description: "Top speed when walking (A/D without Shift). …",
},

The panel writes straight into the settings object, and the game reads it every frame, so changes apply immediately. Values are saved to localStorage, and the Reset button restores the defaults. Hover the ? next to any slider for what it does.

Running and Skidding

Holding Shift raises the top speed from walkSpeed to runSpeed and swaps in the faster run and runJump animations.

Pressing the opposite direction now brakes at the stronger turnAround rate instead of the normal acceleration, so reversing feels decisive. Reversing fast goes further. PlayerWalkingState changes to the new PlayerSkiddingState:

const isReversing = direction !== 0 && Math.sign(velocity) === -direction;
if (
isReversing &&
Math.abs(velocity) > toPixels(PlayerSettings.skidThreshold)
) {
this.player.stateMachine.change(PlayerStateName.Skidding);
}

Skidding shows the skid sprite, kicks up a puff of dust, and brakes until the player stops before handing back to a ground state.

Dust Particles

The dust is a tiny one-shot animation that plays at a fixed spot. Player keeps a list of them and drops each one once it finishes:

createParticle(frames, x, y) {
this.particles.push({
x: Math.round(x),
y: Math.round(y),
animation: new Animation(frames, 0.1, 1),
});
}

The third Animation argument is the number of cycles. Passing 1 plays the frames once, and isDone() reports when it’s over.

Better Jumps

Variable Jump Height

In Mario, tapping jump gives a hop and holding it gives a full jump. applyGravity() gets that by making gravity heavier once Space is released, and heavier again on the way down:

let gravity = getGravity();
if (!this.game.input.isKeyHeld(Input.KEYS.SPACE)) {
gravity *= PlayerSettings.releaseGravity;
}
if (velocity.y > 0) {
gravity *= PlayerSettings.fallGravity;
}

jumpHeight is now the height of a held jump. Releasing early cuts it short. A fallGravity above 1 makes the jump land sooner than it rose, which feels snappier and less floaty.

Coyote Time

Cartoon coyotes run off cliffs and hang in the air for a moment before falling. Coyote time gives players the same grace period. A jump pressed a few frames after walking off a ledge still works. Without it, players feel the game “ate” their jump.

updateTimers() refills coyoteTime while on the ground and counts it down in the air. Walking off a ledge keeps the ground jump until the timer runs out:

if (this.player.isOnGround) {
this.player.coyoteTime = PlayerSettings.maxCoyoteTime;
this.player.jumpsRemaining = getMaxJumps();
} else {
this.player.coyoteTime = Math.max(0, this.player.coyoteTime - dt);
if (
this.player.coyoteTime <= 0 &&
this.player.jumpsRemaining === getMaxJumps()
) {
this.player.jumpsRemaining--;
}
}

Jump Buffering

The opposite mistake is pressing jump a moment before landing. handleAirJumpInput() remembers that press in jumpBuffer, and PlayerFallingState jumps the moment the player lands if the buffer hasn’t run out:

if (this.player.isOnGround) {
if (this.player.jumpBuffer > 0) {
this.player.stateMachine.change(PlayerStateName.Jumping);
} else {
this.changeToGroundState();
}
}

Double Jump

Both features above count jumps with jumpsRemaining, so a double jump is just one more jump. Tick Enable Double Jump and getMaxJumps() returns 2 instead of 1. PlayerJumpingState scales the height of any jump after the first by doubleJumpHeight.

A Smarter Camera

The camera from Mario-2 locked onto the player. Three changes make it smoother. To try them out, the level now continues to the right with a mountain of three tiers.

Lookahead

When you’re running right, what’s ahead matters more than what’s behind. The camera aims ahead of the player in the direction of travel, by up to lookahead tiles, reached at run speed:

const speedRatio = this.player.velocity.x / toPixels(PlayerSettings.runSpeed);
const lookaheadX =
toPixels(CameraSettings.lookahead) * Math.max(-1, Math.min(1, speedRatio));

Damping

Velocity can change in a single frame. Run into a wall and it drops straight to 0. Turn around and it flips sign. If the lookahead followed it directly, the view would lurch two tiles the instant that happens. Instead, the lookahead covers a fraction of the remaining distance each frame, and Camera.ease() does the maths:

ease(value, target, rate, dt) {
if (rate <= 0) {
return target;
}
return value + (target - value) * (1 - Math.exp(-rate * dt));
}
this.lookahead.x = this.ease(
this.lookahead.x,
lookaheadX,
CameraSettings.damping,
dt
);

Big gaps close quickly and small ones slowly, which eases the view to a stop. Using Math.exp() with dt makes it ease the same way at 30 or 144 frames per second.

Vertical Deadzone

The camera remembers the height the player last stood at, lastGroundedY, and only follows a jump or fall once it moves more than half a screen away. Ordinary jumps no longer bob the view up and down.

Climb the mountain on the right of the map to see it. Mid-jump, the view stays put, then it rises to meet you as you land on the upper tiers, because landing updates lastGroundedY. Jump off the summit and the fall is big enough to leave the deadzone, so the camera follows you down.

Turn on Camera Crosshair to see the screen centre and where the lookahead is pointing.

Debug Watch Panel

Debug Watch Panel shows live values in the corner of the page. PlayState registers what to watch with the Debug helper from lib/:

this.debug.watch('Player', {
state: () => player.stateName,
velocity: () =>
`(${player.velocity.x.toFixed(2)}, ${player.velocity.y.toFixed(2)})`,
isOnGround: () => player.isOnGround,
});

Each watch is a function, so it reads the current value every time the panel refreshes. Uncomment the coyoteTime, jumpBuffer, and jumpsRemaining lines in PlayState.js to watch the jump timers tick.

📚 References

Powered by WebContainers
Files
Preparing Environment