Mario-4 adds three moves that each change the player’s shape or the camera. Hold S to duck, hold S while moving to slide, and hold W while standing still to look up. Along the way, the player gets a hitbox that’s separate from their sprite rectangle.
Past the step in the middle of the level there’s a new tunnel only one tile high. You can’t fit standing up. Slide in and you’ll come out the other side.
Two New States
PlayerDuckingState and PlayerSlidingState join the state machine:
stateDiagram-v2 Idling --> Ducking: S held Walking --> Sliding: S held Ducking --> Sliding: still moving Sliding --> Ducking: stopped, S held Ducking --> Idling: S released Sliding --> Idling: stopped, S released Ducking --> Falling: no floor Sliding --> Falling: no floor
Sliding keeps the player’s momentum and slows it with slideFriction, puffing dust every 0.1 seconds with a Timer the state owns. The timer is cancelled in exit(), so the dust stops the moment the slide ends:
enter() { this.crouch(); this.animation = this.player.getAnimation("duck"); this.timer.every(0.1, () => this.player.createDustParticle());}
exit() { this.timer.cancelAll(); this.standUp(); super.exit();}Changing Size
The ducking sprite is 16 pixels tall instead of 24. The collision rectangle has to shrink with it, or the player would collide with ceilings their sprite clearly fits under. Player.setHeight() changes the height while keeping the feet where they are:
setHeight(height) { this.position.y += this.dimensions.y - height; this.dimensions.y = height;}position is the top-left corner, so shrinking by 8 pixels moves the top down by 8 pixels, and the bottom doesn’t move. PlayerState.crouch() and standUp() wrap this for the two heights.
Standing Up Under a Ceiling
Sliding into the tunnel tucks the player under a low ceiling. Standing up there would push their head into a solid tile. Before leaving a crouch, both states call hasRoomToStand() and stay down until there’s room. It checks every row the head would pass through on the way back up to standing height, not just the row the head is in now, which is empty for a player crouched in a tunnel.
That raises a new problem. A slide from walking speed only carries about two tiles, and a stopped player who can’t stand up and can’t walk would be stuck in the tunnel forever. applyFriction() handles it. Under a ceiling, the slide never drops below a slow crawl, so the player always squeezes out the other side:
if (speed < minimumSpeed) { speed = this.hasRoomToStand() ? 0 : minimumSpeed;}Looking Up
Holding W while idle sets player.isLookingUp and shows the look-up sprite. The camera reads that flag and aims up by lookUpOffset tiles:
this.lookahead.y = this.player.isLookingUp ? -toPixels(CameraSettings.lookUpOffset) : 0;Because the camera already eases toward its target, the view pans smoothly with no extra work. PlayerIdlingState.exit() clears the flag so the camera comes back down as soon as the player moves.
Hitboxes
The player’s sprite is 16 pixels wide, exactly one tile. We call that the bounding box. The player could only drop into a one-tile-wide gap if lined up to the exact pixel. That wouldn’t feel very good, right?
A hitbox is a second rectangle, usually a little smaller than the sprite. GameObject now takes offsets that adjust it from the sprite rectangle:
// A slightly narrower hitbox lets the player drop into 1-tile gaps.super(x, y, 16, Player.HEIGHT, { x: 3, y: 0, width: -6, height: 0,});That hitbox starts 3 pixels in from the left and is 6 pixels narrower. That makes it 10 pixels wide instead of 16. getHitbox() recalculates it from the current position whenever it’s asked, so it can never fall out of date.
Play with the offsets here to see how the hitbox moves relative to the sprite’s bounding box:
Standing on the Hitbox
getFeetColumns() now measures the columns under the player from the hitbox instead of the full bounding box. Floors, ceilings, and hasRoomToStand() all use it:
getFeetColumns() { const hitbox = this.player.getHitbox(); const tileSize = this.player.map.tileSize;
return { left: Math.floor(hitbox.x / tileSize), right: Math.floor((hitbox.x + hitbox.width - 1) / tileSize), };}Walls still use the full bounding box, so the sprite never overlaps a wall it’s pushing against. Turn on Player Collision to see both. The blue box is the full bounding box and the green box is the hitbox.