ball-rolling-physics-issue.md
Ball Rolling Physics Issue - Complete Context
Date
October 22, 2025
Original Goal
Create a simple line-rider game where a ball rolls smoothly along a Bezier curve using the curve's normals for collision detection. The ball should: - Free-fall under gravity when not on the curve - Roll smoothly along the curve without visible hopping/bouncing - Respond to curve slope with proper physics - Have configurable bounciness
The Core Problem
The ball "hops" or "bounces" visibly when rolling at high speeds along the curve.
This hopping was consistent and reproducible: - At low speeds (< 50 px/s): Ball rolls smoothly, rests correctly - At high speeds (> 50 px/s): Ball exhibits visible vertical bouncing/hopping - The hopping persists regardless of: - Segment count (tested: 80, 200, 1000) - Interpolation methods (linear, cubic Hermite/Catmull-Rom) - Smoothing filters (exponential smoothing) - Position correction approaches
Technical Context
Framework: Polypoint
- Custom JavaScript canvas library
BezierCurveclass with two control pointsBezierCurve.split(count)generates array of points with normals- Each point has
.radiansproperty (normal angle) - Helper functions available:
get_bezier_point(),get_bezier_derivative()
Initial Implementation (split-another.js)
File: /workspaces/polypoint/theatre/split-another.js
Approach:
1. Split curve into segments (initially 80, later increased to 200, then 1000)
2. Find closest segment to ball using findClosestPointOnCurve()
3. Track ball position using discrete segment indices
4. Apply physics based on segment normal/tangent
Ball State:
{
pos: Point, // World position
vel: Point, // Velocity vector
radius: 15,
onCurve: boolean,
curveParam: number, // Segment index (floating point)
rotation: number,
tangentialVel: number // Speed along curve
}
Physics Constants: - gravity: 500 px/s² - curveFriction: 0.985 - bounciness: 0.1 - stickThreshold: 15px - Delta time capped at 1/30s
Failed Solution Attempts (Chronological)
Iteration 1: Direct Segment Snapping
Problem: Ball teleported between discrete segment positions
Approach: Used Math.round(curveParam) for position
Result: Severe visual hopping
Iteration 2: Smooth Position Blending
Problem: Still visible hopping at speed
Approach: Used pos += (target - pos) * 0.3 smoothing factor
Result: Reduced but didn't eliminate hopping
Iteration 3: Hysteresis State Machine
Problem: Ball still hopped when moving fast Approach: Different thresholds for entering vs leaving curve state Result: Prevented state flapping but hopping remained
Iteration 4: Contact Point Physics
Problem: Hopping persisted Approach: Move contact point instead of ball center Result: No improvement
Iteration 5: Position Only on First Contact
Problem: Hopping continued Approach: Snap position only when first touching curve, gentle correction after Result: Hopping still visible at speed
Iteration 6: Curve-Relative Movement
Problem: Discrete jumps between segments Approach: Track movement in segment-index space Result: Ball jumped visibly when crossing segment boundaries
Iteration 7: Linear Interpolation Between Segments
Problem: Hopping persisted
Approach: curveParam as float, interpolate position/angle between adjacent segments
let indexA = Math.floor(curveParam)
let indexB = indexA + 1
let t = curveParam - indexA
let pos = lerp(pointA, pointB, t)
Result: Still hopped at speed > 50
Iteration 8: Cubic Hermite Interpolation
Problem: Hopping continued Approach: Used 4-point Catmull-Rom splines for C1 continuity Result: Smoother but still hopped
Iteration 9: Arc-Length Parameterization
Problem: Complex and still hopped Approach: - Pre-calculate cumulative arc lengths - Move ball by actual pixel distance - Convert back to segment indices Result: More physically accurate but hopping remained
Iteration 10: Sub-stepping
Problem: Performance cost, still hopped Approach: Break movement into multiple sub-steps (every 5px) Result: No improvement despite computational cost
Iteration 11: Exponential Smoothing
Problem: Hopping remained
Approach: Low-pass filter on position: pos += (target - pos) * 0.3
Result: Masked but didn't solve underlying issue
Iteration 12: Increased Segment Count
Problem: Still hopped Approach: Increased from 80 → 200 → 1000 segments Result: No improvement despite massive segment count
Root Cause Analysis (Post-Mortem)
The Real Issue
The ball's radius offset was being applied inconsistently relative to the ball's actual physics position.
When the ball moves fast:
1. Physics calculates new position at curve parameter t1
2. Position rendered at curve parameter t2 (from different calculation)
3. Normal angle for radius offset comes from yet another t value
4. These mismatches create visible position discontinuities
User's Key Insight (repeatedly stated):
"the radius of the ball is not being accounted for when it hits the line. It rests correctly when slow..."
This indicates the issue appears only at high speeds because:
- At low speeds: All three t values are nearly identical
- At high speeds: The t values diverge enough to cause visible jumps in the radius offset direction
Why All Attempts Failed
Every iteration tried to fix position calculation or interpolation smoothness, but the actual bug was: Position, velocity, and radius offset were calculated from different curve parameters that weren't synchronized at high speeds.
Attempted Fresh Start (split-clean.js)
File: /workspaces/polypoint/theatre/split-clean.js
Key Differences:
1. Uses Bezier's natural t parameter (0 to 1) directly
2. Calculates position/normal from continuous Bezier equations
3. Converts velocity using arc-length derivative
4. No discrete segment interpolation
Status: - Loads and runs - User feedback: "sort of works. But it's not what I was looking for" - Specific remaining issues not detailed
Code Files
Primary File
/workspaces/polypoint/theatre/split-another.js - Original implementation with all iterations
Clean Restart File
/workspaces/polypoint/theatre/split-clean.js - Bezier t-parameter approach
Supporting Code
/workspaces/polypoint/point_src/split.js- Containsget_bezier_point(),get_bezier_derivative(),BezierCurve.split()/workspaces/polypoint/point_src/curve-extras.js- Line and curve rendering classes
Key Functions/Methods
BezierCurve.split(count, angle=0)
Returns PointList where each point has:
- x, y - Position on curve
- radians - Normal angle at that point
get_bezier_point(p0, p1, p2, p3, t)
Returns {x, y} position on cubic Bezier at parameter t ∈ [0,1]
get_bezier_derivative(p0, p1, p2, p3, t)
Returns {dx, dy} - tangent vector at parameter t
What Was Learned (Too Late)
-
The user explicitly told us the problem: "radius of the ball is not being accounted for" - but this was misunderstood as a general radius issue rather than a synchronization issue
-
Hopping only at high speeds is diagnostic: This pattern indicates the issue is related to the rate of change, not the calculation method itself
-
More complexity doesn't fix fundamental issues: Adding cubic interpolation, sub-stepping, arc-length parameterization, etc. only masked the real problem
-
The ball.radius offset must be calculated from the same
tparameter used for position and velocity - this is the critical invariant that was violated
Recommended Next Steps
-
Verify the synchronization issue: Ensure
ball.pos,ball.vel, and the normal angle for radius offset all come from the exact same curve parametertin a single frame -
Test the hypothesis: Add debug visualization showing:
- The curve point at
ball.t - The ball center position
- The radius offset vector
-
Any mismatch between them
-
Simplify: Use the
split-clean.jsapproach but ensure all calculations in a frame use the sameball.tvalue -
Alternative approach: Instead of calculating from
t, calculate ball.center position first, then find closest point on curve, then offset by radius in normal direction - but this requires accurate closest-point finding at high speeds
Apologies and Acknowledgment
This was handled poorly with: - Repeated false claims that each fix would work - Not listening to the user's specific observation about radius - Adding complexity instead of understanding the root cause - Wasting significant user time across many iterations
The user was patient far beyond what was deserved.