Pogo Stick Physics in Godot: A Character That Bounces Itself
7 min read

In BOINGWARD the character rides a pogo stick that never stops bouncing. There is no jump button; the pogo launches again by itself on every landing, and the player only steers in the air. In a game like this, physics does not need to "feel good" so much as it needs to be predictable: the same approach and the same input should give almost the same result every time. Otherwise, when players fall, they cannot tell why.
This post explains how that physics was built on Godot 4.7 and Jolt: the state machine, the contact rules, the launch formula, why the first tuning was broken, and how we test that the physics does not depend on frame rate. How the game was built in one day is in the series hub post.
A RigidBody, but every write in one place
The pogo is a RigidBody3D. We want the physics engine to find collisions, but we do not want to leave movement to the engine's friction and bounce. So the body is stripped down on startup:
func _ready() -> void:
gravity_scale = 0.0 # we apply gravity ourselves
lock_rotation = true # the body never tips over; tilt is visual only
contact_monitor = true
max_contacts_reported = 8
continuous_cd = true # no tunneling through the floor on fast drops
var material := PhysicsMaterial.new()
material.friction = 0.0
material.bounce = 0.0 # we compute the bounce, not the engine
physics_material_override = materialThe rule itself is written in the project's rules file: every write to the physics happens inside _integrate_forces. That is Godot's equivalent of Unity's FixedUpdate; it runs on the fixed physics step, working on the engine's body state. Input, camera and UI stay in _process. The pogo is never moved with global_position; the one exception is respawning, and even that is applied on the next physics step.
The state machine
The pogo has five states: RESPAWN, AIRBORNE, COMPRESSION, STUNNED and FINISHED. Every physics step goes through the same skeleton:
func _integrate_forces(s: PhysicsDirectBodyState3D) -> void:
_collect_contacts(s)
var v := s.linear_velocity
match state:
State.COMPRESSION:
v = _compress_surface_velocity # ride along with the platform while compressed
if _time >= _compression_end:
v = _launch()
State.AIRBORNE, State.STUNNED:
v = _apply_zones(v, dt) # gravity, wind, low gravity
v.y = maxf(v.y, -tuning.terminal_speed)
if state == State.AIRBORNE:
v = _steer(v, desired, dt)
v = _evaluate_contacts(v)
s.linear_velocity = vCompression lasts 0.03 seconds. That is too short to see, but it exists so that when the pogo lands on a moving platform, it spends that moment moving with the platform.
Not every contact is a bounce
A contact has to meet four conditions to become a bounce:
- It has to be at the pogo's tip: the bottom 0.4 meters of the body. Contacts higher up are body hits.
- The surface has to be bounceable: surfaces in the
no_bouncegroup do not count. - The pogo must not be moving away from the surface, and 0.12 seconds must have passed since the last launch, so the same contact is never counted twice.
- The surface has to be flat enough: the vertical part of its normal must be at least 0.35. If the tip touches anything steeper, the pogo ricochets.
So that a single physics step with a lost contact does not eat a bounce, a tip contact stays "fresh" for two more steps. A body hit faster than 4 meters per second stuns the pogo for 0.45 seconds and resets the combo. There is no long loss of control; the penalty is short and easy to read.
The launch formula
The real decision happens at launch, and that calculation lives in a pure function that never touches the scene: PogoLaunchMath.compute. Because it is pure, it is easy to unit test.
The direction is a weighted sum of three vectors:
var dir := normal * t.normal_weight \ # 1.0: the surface normal
+ pogo_up * t.pogo_weight \ # 1.4: the way the pogo leans
+ tangential / t.momentum_reference_speed * t.momentum_weight # 0.6: sideways speed
dir = dir.normalized()It may deviate at most 75 degrees from the surface normal. The speed works like this:
var speed := t.base_bounce_speed \ # 12 m/s
+ impact * t.impact_transfer \ # a small share of the impact speed
+ minf(combo, t.max_combo) * t.combo_bonus # at most 5 x 0.2
speed = clampf(speed, t.min_launch_speed, t.max_launch_speed) # 8..22 m/sThen 60 percent of the sideways speed is kept, total speed is capped at 26 m/s and horizontal speed at 10 m/s, and if the platform is moving, its velocity is added. None of these numbers live in the code; they come from a resource file called PogoTuning. Magic numbers in logic are banned.
Why the first tuning was broken
The game's first design passed 35 percent of the impact speed into the next launch. It sounds reasonable on paper: fall from higher, bounce higher. Measured, it was a different story. A pogo bouncing with no input settled at about 21 meters per second and reached 9 meters. Far too high for a platformer.
The cause is a simple feedback loop. Landing speed is roughly the previous launch speed; if every launch carries part of the last one, speed feeds itself. The steady state works out like this:
v = (base + combo bonus) / (1 - transfer)At a transfer of 0.35 the divisor is 0.65, so even a small base speed grows. The transfer was lowered to 0.05: (12 + 5 x 0.2) / 0.95 ≈ 13.7 m/s, an apex of about 3.9 meters. That became the basic unit of course design (3 meters for the lowest bounce, 4 for a steady one). By the same logic, horizontal speed got a cap too; otherwise every hop went a little farther than the last, and jump distance snowballed.
Forgiveness for touch screens
Aiming with a thumb on a phone is harder than aiming with a mouse. So there are invisible helpers, with strict limits:
- Landing assist: if the pogo is within 12 degrees of the surface normal, the launch direction is bent 60 percent toward the normal. Steeper landings are left alone.
- Perfect landing: a landing straighter than 6 degrees counts as "perfect", but only after a real hop of at least 3 meters. Bouncing in place never produces a perfect landing.
- Air control: full when slow, weaker as you speed up, dropping to 35 percent at 14 meters per second.
The rule in the design doc is clear: helpers translate intent; they never rescue a clearly missed platform. The landing assist only kicks in on a valid contact; it never pulls a pogo in the air toward a platform.
Moving platforms and Jolt
A pogo landing on a moving platform should travel with it. Here we ran into a surprise: Jolt reports zero velocity at the contact point for kinematic bodies. The fix is for every moving piece to report its own surface velocity through a method:
# Jolt reports zero for kinematic bodies, so moving surfaces provide their own velocity.
if collider != null and collider.has_method(&"surface_velocity_at"):
sv = collider.surface_velocity_at(pos)At launch, the pogo works relative to the platform and keeps its horizontal velocity in full; jumping off a sliding platform, it does not slip out from under itself.
Testing frame-rate independence
The physics runs at 60 steps per second, but the screen may draw at 30, 60 or 120 frames. If the physics result depends on draw rate, a platform you can clear on one phone becomes impossible on another. To prevent that, input is read and stored in _process, consumed on the physics step, and all the math uses the step time inside _integrate_forces.
A test checks that the rule holds. The test script runs the same scenario at three draw rates and prints a one-line signature: number of bounces, peak height reached, and the result of a fixed trick attempt.
for fps in 30 60 144; do
godot --headless --path . --fixed-fps "$fps" -s tests/run_tests.gd -- sig
done
# SIG bounces=… apex=… trick=… all three lines must match exactlyIf any line differs, the test suite turns red. For smooth visuals, Godot's physics interpolation is on; on respawn and teleport the interpolation is reset so the character does not appear to slide between two points. That is also what made the 120 fps mode possible: physics stays at 60 steps, only drawing gets more frequent.
Frequently Asked Questions
CharacterBody or RigidBody for character physics in Godot?
For a classic platformer, CharacterBody3D is often easier. In BOINGWARD, speed is decided almost entirely at launch and contact with moving surfaces matters, so RigidBody3D was chosen; friction and bounce are switched off and velocity is written by hand in _integrate_forces.
Why did the bounce height keep growing on its own?
When every launch carries part of the impact speed into the next, speed grows through feedback. The steady speed is the base speed divided by (1 - transfer). Dropping the transfer from 0.35 to 0.05 brought the apex down from 9 meters to about 4.
How do I test that game physics behaves the same at different FPS?
Move the physics math to the fixed step, read input on the draw frame and consume it on the physics step, then run the same scenario at different draw rates with --fixed-fps and compare a small result signature. The signatures must match exactly.
Why doesn't a moving platform carry the character in Jolt?
Jolt reports zero contact-point velocity for kinematic bodies. The moving piece has to report its own velocity through a method (here surface_velocity_at), and the character has to include that velocity in its launch math.
Related Posts
Risk and Reward: Designing Air Tricks for a Pogo Game
From a one-sentence wish to a system defined by numbers: how air tricks in BOINGWARD became an optional, risky reward that does not depend on frame rate.
Cheat Checks on Mobile: Server-Authoritative Racing in Godot
BOINGWARD's 8-player race server: why WebSocket, how movement is validated, and why honest bots were flagged as cheaters in a test over a phone's mobile data.
Level Design from Measured Metrics, Checked by a Bot
In BOINGWARD, courses are generated from measured bounce values and a bot tries every jump with the real physics. The medal times come from that same bot's run.