Skill v1.0.1
currentAutomated scan100/100+5 new
name: celeste-momentum-platforming description: "Celeste-style 2D precision platformer movement — momentum running, coyote time, variable jumps, dash, wall jump/slide, climb stamina, pixel corner correction." license: MIT category: game-feel tags:
- platformer
- movement
- game-feel
- controller
- godot4
confidence: extracted metadata: version: "1.0.1"
Celeste-Style Momentum Platforming (Movement System)
Outcome
A 2D character controller for Summer Engine that reproduces the tuning architecture and forgiveness mechanics of Celeste's player movement: momentum-based running with distinct ground/air acceleration, variable-height jumping with coyote time, a multi-directional dash with cooldown and residual speed, wall interaction (jump, slide, climb with stamina), and pixel-scale corner corrections that make near-miss inputs succeed instead of fail.
When to Use
- Building a precision 2D platformer in Summer Engine where movement feel is the core gameplay.
- Porting or prototyping Celeste-style mechanics (dash, wall jump, climb stamina).
- Tuning an existing platformer controller that feels "unfair" or unresponsive — the forgiveness systems below are the primary fix.
Do not use for: 3D character controllers, physics-driven (RigidBody) movement, or games where floaty/arcade jump physics are desired over momentum precision.
Core Principle
Celeste's movement is a single controller class driven by an explicit integer state machine, with all feel-critical numbers as a flat compile-time constants table at the top of the class. The released source (Player.cs, 5,471 lines) defines states Normal=0, Climb=1, Dash=2, Swim=3, Boost=4, RedDash=5, HitSquash=6, Launch=7, Pickup=8, DreamDash=9 (+ a Summit state), each with Begin/Update/End callbacks. State transitions are direct integer assignments.
The second principle: forgiveness at the pixel and millisecond scale. The code repeatedly nudges the player 2–6 pixels or grants 50–200 ms windows so that inputs which are almost correct still succeed. Most of the "tight" feel comes from these windows.
Tuning Constants (verbatim from Player.cs)
Celeste's units are pixels/second for speeds and pixels/second² for accelerations at its native resolution; times in seconds. When porting, preserve the ratios more than the absolute numbers if your pixels-per-meter differs.
Gravity & Falling
| Constant | Value | Feel role | |
|---|---|---|---|
| Gravity | 900 | Base downward acceleration | |
| MaxFall | 160 | Terminal fall speed (normal) | |
| HalfGravThreshold | 40 | Below this vertical speed, gravity is halved (floaty apex) | |
| FastMaxFall | 240 | Terminal fall speed when fast-falling (hold down) | |
| FastMaxAccel | 300 | Acceleration toward FastMaxFall |
Running
| Constant | Value | Feel role | |
|---|---|---|---|
| MaxRun | 90 | Top ground speed | |
| RunAccel | 1000 | Ground acceleration (~11× MaxRun — near-instant response) | |
| RunReduce | 400 | Deceleration when input stops | |
| AirMult | 0.65 | Air control multiplier on acceleration | |
| HoldingMaxRun | 70 | Reduced top speed while carrying objects | |
| HoldMinTime | 0.35 | Min time before hold-reduction applies | |
| WalkSpeed | 64 | Slow-walk speed | |
| DuckFriction | 500 | Friction while crouched |
Jumping
| Constant | Value | Feel role | |
|---|---|---|---|
| JumpGraceTime | 0.1 | Coyote time — jump input valid 100 ms after leaving ground | |
| JumpSpeed | -105 | Initial jump velocity | |
| JumpHBoost | 40 | Horizontal boost added on jump | |
| VarJumpTime | 0.2 | Variable jump — held jump sustains/boosts for up to 200 ms | |
| CeilingVarJumpGrace | 0.05 | Grace before ceiling cancels variable jump | |
| UpwardCornerCorrection | 4 | px nudge to slip past near-miss ceiling corners while rising | |
| WallSpeedRetentionTime | 0.06 | Horizontal speed retained briefly after wall interactions | |
| BounceAutoJumpTime | 0.1 | Auto-jump window off bouncy surfaces |
Wall Interaction
| Constant | Value | Feel role | |
|---|---|---|---|
| WallJumpCheckDist | 3 | px probe distance for wall detection | |
| WallJumpForceTime | 0.16 | Duration of forced horizontal speed after wall jump | |
| WallJumpHSpeed | 130 | = MaxRun + JumpHBoost; forced away-from-wall speed | |
| WallSlideStartMax | 20 | Initial wall slide speed cap | |
| WallSlideTime | 1.2 | Time over which wall slide behavior ramps | |
| ClimbGrabYMult | 0.2 | Vertical speed multiplied by 0.2 on grab |
Super Variants
| Constant | Value | |
|---|---|---|
| SuperJumpH | 260 | |
| SuperWallJumpSpeed | -160 | |
| SuperWallJumpH | 170 (= MaxRun + JumpHBoost×2) | |
| SuperWallJumpForceTime | 0.2 | |
| SuperWallJumpVarTime | 0.25 | |
| BounceSpeed / SuperBounceSpeed | -140 / -185 | |
| DuckSuperJumpXMult / YMult | 1.25 / 0.5 |
Dash
| Constant | Value | Feel role | |
|---|---|---|---|
| DashSpeed | 240 | Speed during dash | |
| DashTime | 0.15 | Dash duration | |
| EndDashSpeed | 160 | Residual speed after dash ends (2/3 of dash speed) | |
| EndDashUpMult | 0.75 | Residual multiplier for upward dashes | |
| DashCooldown | 0.2 | Time before dash input re-accepted | |
| DashRefillCooldown | 0.1 | Refill timing after landing | |
| DashAttackTime | 0.3 | Active dash collision window | |
| DashCornerCorrection | 4 | px nudge on dash near-misses | |
| DashHJumpThruNudge | 6 | px nudge for horizontal dashes through one-way platforms | |
| DashVFloorSnapDist | 3 | px snap to floor after downward dash |
Climb / Stamina
| Constant | Value | Feel role | |
|---|---|---|---|
| ClimbMaxStamina | 110 | Total stamina pool | |
| ClimbUpCost | ~45.5/s (100/2.2) | Climbing upward drains ~2.4 s of pool | |
| ClimbStillCost | 10/s (100/10) | Hanging drains ~11 s of pool | |
| ClimbJumpCost | 27.5 (110/4) | Flat cost per climb-jump | |
| ClimbTiredThreshold | 20 | Below this, climb behavior degrades | |
| ClimbUpSpeed | -45 | Climb ascent speed | |
| ClimbDownSpeed | 80 | Climb descent speed | |
| ClimbSlipSpeed | 30 | Slip speed when exhausted | |
| ClimbAccel | 900 | Climb velocity acceleration | |
| ClimbCheckDist / UpCheckDist | 2 / 2 | px probe for grab detection | |
| ClimbHopX / HopY | 100 / -120 | Wall-hop impulse | |
| ClimbHopForceTime | 0.2 | Forced duration of hop impulse | |
| ClimbGrabYMult | 0.2 | Vertical damp on grab |
Other
| Constant | Value | |
|---|---|---|
| LaunchSpeed / LaunchCancelThreshold | 280 / 220 | |
| LiftYCap / LiftXCap | -130 / 250 | |
| JumpThruAssistSpeed | -40 (upward assist through one-way platforms) | |
| ReboundSpeedX / Y | 120 / -120 | |
| ReflectBoundSpeed | 220 | |
| ThrowRecoil | 80 | |
| DuckWindMult | 0 (ducking negates wind) | |
| SpacePhysicsMult | 0.6 (low-gravity zones) |
Scene / Node Shape
Player (CharacterBody2D)├── CollisionShape2D # compact rectangle; Celeste probes at 2–6 px, so shape must be tight├── WallProbeLeft (Area2D) # or use move_and_collide probes at WallJumpCheckDist├── WallProbeRight (Area2D)├── StateMachine (ref in script)└── (AnimatedSprite2D / hair, particles optional)World├── TileMapLayer (static solids)├── OneWayPlatforms (TileMapLayer or StaticBody2D with one-way collision)└── JumpThru zones
Reference Implementation Skeleton (GDScript)
extends CharacterBody2D# === Tuning table (values from Celeste Player.cs; rescale px if needed) ===const GRAVITY := 900.0const MAX_FALL := 160.0const HALF_GRAV_THRESHOLD := 40.0const FAST_MAX_FALL := 240.0const FAST_MAX_ACCEL := 300.0const MAX_RUN := 90.0const RUN_ACCEL := 1000.0const RUN_REDUCE := 400.0const AIR_MULT := 0.65const JUMP_GRACE_TIME := 0.1const JUMP_SPEED := -105.0const JUMP_H_BOOST := 40.0const VAR_JUMP_TIME := 0.2const UPWARD_CORNER_CORRECTION := 4const WALL_SPEED_RETENTION_TIME := 0.06const WALL_JUMP_CHECK_DIST := 3const WALL_JUMP_FORCE_TIME := 0.16const WALL_JUMP_H_SPEED := MAX_RUN + JUMP_H_BOOST # 130const DASH_SPEED := 240.0const DASH_TIME := 0.15const END_DASH_SPEED := 160.0const END_DASH_UP_MULT := 0.75const DASH_COOLDOWN := 0.2const DASH_CORNER_CORRECTION := 4const CLIMB_MAX_STAMINA := 110.0const CLIMB_UP_COST := 100.0 / 2.2const CLIMB_STILL_COST := 100.0 / 10.0const CLIMB_JUMP_COST := 110.0 / 4.0const CLIMB_UP_SPEED := -45.0const CLIMB_DOWN_SPEED := 80.0const CLIMB_SLIP_SPEED := 30.0const CLIMB_CHECK_DIST := 2enum St { NORMAL, CLIMB, DASH, SWIM, BOOST, RED_DASH, HIT_SQUASH, LAUNCH, PICKUP, DREAM_DASH }var state: int = St.NORMALvar var_jump_timer: float = 0.0var jump_grace_timer: float = 0.0 # coyote timevar dash_cooldown_timer: float = 0.0var dash_timer: float = 0.0var dash_dir: Vector2 = Vector2.ZEROvar wall_jump_force_timer: float = 0.0var wall_speed_retention_timer: float = 0.0var stamina: float = CLIMB_MAX_STAMINAfunc _physics_process(delta: float) -> void:match state:St.NORMAL: _normal_update(delta)St.DASH: _dash_update(delta)St.CLIMB: _climb_update(delta)# ... remaining statesmove_and_slide()func _normal_update(delta: float) -> void:# --- Gravity with apex float and fast-fall ---var max_fall := MAX_FALLif Input.is_action_pressed("ui_down") and not is_on_floor():max_fall = FAST_MAX_FALLvar grav := GRAVITYif absf(velocity.y) < HALF_GRAV_THRESHOLD and velocity.y < 0.0:grav *= 0.5velocity.y = minf(velocity.y + grav * delta, max_fall)# --- Run acceleration, reduced in air ---var input_x := Input.get_axis("ui_left", "ui_right")var accel := RUN_ACCEL if is_on_floor() else RUN_ACCEL * AIR_MULTif input_x != 0.0:velocity.x = move_toward(velocity.x, input_x * MAX_RUN, accel * delta)else:velocity.x = move_toward(velocity.x, 0.0, RUN_REDUCE * delta)# --- Coyote time bookkeeping ---if is_on_floor():jump_grace_timer = JUMP_GRACE_TIMEelse:jump_grace_timer -= delta# --- Jump with variable height ---if Input.is_action_just_pressed("ui_accept") and jump_grace_timer > 0.0:velocity.y = JUMP_SPEEDvelocity.x += signf(input_x) * JUMP_H_BOOST if input_x != 0.0 else 0.0var_jump_timer = VAR_JUMP_TIMEjump_grace_timer = 0.0if var_jump_timer > 0.0 and Input.is_action_pressed("ui_accept"):var_jump_timer -= delta# hold sustains upward velocity; release cuts itelif var_jump_timer > 0.0:var_jump_timer = 0.0if velocity.y < 0.0:velocity.y *= 0.5 # jump cut on release (standard platformer pattern)# --- Wall probe and wall jump ---var wall_dir := _probe_wall() # raycast WALL_JUMP_CHECK_DIST px each sideif wall_dir != 0 and Input.is_action_just_pressed("ui_accept"):velocity.x = -wall_dir * WALL_JUMP_H_SPEEDwall_jump_force_timer = WALL_JUMP_FORCE_TIMEvelocity.y = JUMP_SPEEDvar_jump_timer = VAR_JUMP_TIMEif wall_jump_force_timer > 0.0:wall_jump_force_timer -= delta # lock horizontal input during forced push# --- Dash entry ---if dash_cooldown_timer <= 0.0 and Input.is_action_just_pressed("dash"):state = St.DASHdash_dir = _aim_direction() # 8-way from input axesdash_timer = DASH_TIMEdash_cooldown_timer = DASH_COOLDOWNvelocity = dash_dir * DASH_SPEEDfunc _dash_update(delta: float) -> void:dash_timer -= deltavelocity = dash_dir * DASH_SPEED # no gravity during dashif dash_timer <= 0.0 or _collided_during_dash():state = St.NORMAL# Residual speed: keep EndDashSpeed, reduced for upward dashesvar residual := END_DASH_SPEEDif dash_dir.y < 0.0:residual *= END_DASH_UP_MULTvelocity = dash_dir.normalized() * minf(velocity.length(), residual)func _climb_update(delta: float) -> void:var input_y := Input.get_axis("ui_up", "ui_down")if input_y < 0.0:velocity.y = CLIMB_UP_SPEEDstamina -= CLIMB_UP_COST * deltaelif input_y > 0.0:velocity.y = CLIMB_DOWN_SPEEDelse:velocity.y = minf(velocity.y + GRAVITY * delta * 0.1, CLIMB_SLIP_SPEED)stamina -= CLIMB_STILL_COST * deltavelocity.x = 0.0if stamina <= 0.0 or not _touching_wall():state = St.NORMALif Input.is_action_just_pressed("ui_accept"):stamina -= CLIMB_JUMP_COSTstate = St.NORMALvelocity.y = JUMP_SPEEDfunc _probe_wall() -> int:# Space-aware probe: returns -1 (wall left), 1 (wall right), 0 (none)# Use move_and_collide or a WALL_JUMP_CHECK_DIST raycast against solids.return 0
The skeleton above is a structural translation to GDScript, not a line-by-line port of the per-frame update bodies in Player.cs. The constants, state IDs, and confirmed behaviors (corner correction loops, variable-jump window logic, forced wall-jump timers, stamina costs, squish safety) are extracted directly from the source.
Confirmed Behavioral Patterns to Replicate
- Corner correction: on near-miss collisions (rising jump, dash), loop through ±4 px perpendicular offsets, test for clearance, and shift the player to the first clear position. Do not fail the move; nudge it through.
- Variable-jump window:
varJumpTimerruns up to 0.2 s; a ceiling grace of 0.05 s prevents premature cancellation at the apex. The window zeroes on specific collision conditions, not on a raw timer expiry alone. - Forced wall-jump time: for 0.16 s after a wall jump, horizontal input does not override the forced 130 px/s push. This prevents immediate re-stick to the wall.
- Wall speed retention: a 0.06 s timer retains horizontal speed across wall interactions;
NormalEndresets it along withwallBoostTimerandhopWaitX. - Squish safety: if crushed, try (in order) ducking, repositioning to target, wiggle-nudging; only kill the player if all fail. Never instakill on a recoverable geometric overlap.
- Jump-through assist: one-way platforms apply a -40 (gentle upward) assist speed during pass-through.
- Dash floor snap / jump-thru nudge: after downward dashes, snap up to 3 px to the floor; horizontal dashes nudge up to 6 px through jump-throughs.
Implementation Steps
- Create the constants table first, exactly as listed. Keep it at the top of the player script as the single tuning surface (mirroring Player.cs structure).
- Implement the state machine as an enum + match, with per-state begin/update/end functions. Start with NORMAL, DASH, CLIMB; add BOOST, HIT_SQUASH, LAUNCH as encounters require.
- Implement gravity/fall tuning (apex float via HalfGravThreshold, fast-fall, MaxFall clamp) and run acceleration (RunAccel/RunReduce/AirMult).
- Implement jump + coyote time + variable jump height + jump cut on release.
- Implement wall probing (3 px), wall jump with forced-time lock, and wall slide.
- Implement dash with 8-way aim, cooldown, residual EndDashSpeed, and corner correction.
- Implement climb stamina with the three cost rates and tired threshold.
- Add forgiveness pass: upward corner correction on jump, dash nudges, jump-through assist, wall speed retention, squish-safety cascade.
- Rescale units if your game's pixels-per-meter differs from Celeste's native resolution: preserve ratios (e.g., DashSpeed ≈ 2.67× MaxRun; RunAccel ≈ 11× MaxRun).
Tunables
All constants in the tables above. The highest-leverage feel knobs, in order:
JUMP_GRACE_TIME(coyote) andVAR_JUMP_TIME— responsiveness.RUN_ACCELvsAIR_MULT— ground crispness vs air drift.DASH_SPEED/DASH_TIME/END_DASH_SPEED— dash punch and recovery.WALL_JUMP_FORCE_TIMEandWALL_JUMP_H_SPEED— wall jump commitment.HALF_GRAV_THRESHOLD— apex float amount.
Failure Modes & Gotchas
- Skipping coyote time or variable jump makes the controller feel unfair even if every other value matches. These two forgiveness systems carry most of the "tight" perception.
- Letting horizontal input override the wall-jump force window causes players to snap back onto the wall immediately.
- Applying RunAccel in the air without AirMult gives full ground control mid-air and destroys momentum feel.
- Hard-failing near-miss collisions instead of corner-correcting (±2–6 px probes) turns visual grazes into deaths.
- Instakilling on squish — the source cascades duck → reposition → wiggle → die. Replicate the cascade.
- Treating stamina costs as per-frame instead of per-second drains the pool ~60× faster.
- Copying pixel values without rescaling if your game's world scale differs — use ratios.
- Over-abstracting the controller. The source is deliberately one flat class with a constants table; splitting it into many components before the feel is dialed makes tuning iterations slow.
Verification
After implementing, test each forgiveness system explicitly:
- Walk off a ledge and press jump up to ~100 ms later — should still jump (coyote).
- Tap vs. hold jump — distinct short and full arc heights (variable jump).
- Jump diagonally into a ceiling corner within ~4 px of clearance — should slip past, not bonk (corner correction).
- Wall jump and hold toward the wall — should still be pushed away for ~0.16 s.
- Dash into a wall edge within ~4 px — should correct rather than stop dead.
- Climb until stamina hits 0 — transition to slip/fall, not instant drop.
- Land from a dash — residual speed ≈ 160 px/s (or ×0.75 for upward), not zero.
- Get crushed by a moving platform — duck/wiggle recovery before any death.
Confidence
extracted — All tuning values, state IDs, and confirmed behavioral patterns come from Celeste's publicly released Player.cs. Where the skeleton fills in standard platformer patterns (for example jump-cut-on-release), it is a structural translation, not extracted code. Not yet verified inside Summer Engine.