Risky Rapids
Overview
- Written in: Unity / C#
- Development period: 4 weeks
- Team size: 17 people, of which 4 programmers
- Source repository
- Itch page
With only 4 programmers on the team, I worked on many aspects of the game. My most significant contribution was the river physics for the boat, the core mechanic of the game. The differently shaped rivers of the multiple levels were made by the level designers using a spline, so I had to make a system that can apply the proper forces to the boat based on the shape of the spline and where on it the boat is.

I put a Unity Rigidbody on the boat's GameObject so it could accumulate velocity/angular velocity from applied force/torque. None of the force/torque comes from collisions in Unity's physics system. All force/torque that affect the boat are applied in code, by my system. The rowing done by players applies force and torque to the boat's rigidbody through my system. The forces this system had to calculate were:
- The force of the river flow pulling the boat along.
- A slight torque that makes the boat tend towards pointing in the same direction as the flow.
- Buoyancy so the boat rises back to the surface after plunging into water.
- Knockback when hitting the edge of the river's fixed width (where the visual terrain is at).
These required forces were decided during the initial back-and-forth (including some prototyping) between me and the level designers. We also decided that the normal vector of the river's surface in each level must be the same for the entire river. This was because I determined that making the boat's river physics work properly on a curved surface would be very difficult and take too much time. We did however realize we could have a vertical drop mid-river, a waterfall basically, in the river, without much extra code at all.

Side and top-down views of an allowed river shape in the initial design of the system. When viewed from above it curves clearly, to the left and right. When viewed from the side it's a simple straight line, save for the vertical drop in the middle.
To calculate the different forces on the boat, my system first finds the two relevant control points on the spline. The first point is the closest point to the boat behind it, and the second is the closest point in front. I then approximate the local flow direction of the river to be the direction from the first point to the second (taken from a pre-computed array since the spline doesn't change during gameplay).
Approximating the flow instead of precisely calculating it greatly simplifies the required math and reduces heavy computation, without compromising the gameplay feel or level design. Very curved river sections can be created without a large approximation error by increasing the density of control points, and the few percent of difference between the actual spline direction and the approximate one is unnoticeable in the fast paced gameplay.
![private void CalculateSplinePosition()
{
float minDistance = float.MaxValue;
int splineIndex = -1;
for (int i = 0; i < splinePositions.Length; i++)
{
float distance = ProjectOnXZPlane(splinePositions[i]-transform.position).magnitude;
if (minDistance > distance)
{
minDistance = distance;
splineIndex = i;
}
}
Vector3 closestNonZeroFlowDirection = ProjectOnXZPlane(splineDirections[Math.Min(splineIndex, splineDirections.Length-2)]);
Vector3 splinePositionToBoat = ProjectOnXZPlane(splinePositions[splineIndex]-transform.position);
bool isBeforePoint = 0 < Vector3.Dot(closestNonZeroFlowDirection, splinePositionToBoat);
if (isBeforePoint){
splineIndex--;
if (splineIndex < 0)
{
localFlowDirection = Vector3.zero;
return;
}
}
localSplinePosition = splinePositions[splineIndex];
localFlowDirection = splineDirections[splineIndex];
nextSplinePosition = splinePositions[Math.Min(splineIndex+1, splinePositions.Length-1)];
}](static/images/risky rapids code1.png)
Every physics update, the system applies force in the local flow direction to the boat's rigidbody. It also applies a torque that depends on the angle between the boat's forward vector and the flow direction vector.
The buoyant force and the knockback from the river's edge require more vector math to create than the river's flow. The buoyancy calculation projects the boat's position onto the plane of the river to compare how high/low it is above/below the water's surface (more on how it knows which normal to use for the plane in a bit). The knockback calculation projects the boat's position not onto a line after projecting it to the river plane. Specifically the straight line between the two relevant spline points. If the boat is further from the projected point than the river's half width it must be overlapping the riverbank, so force is applied based on how much it overlaps.
All the forces except riverbank knockback in action. Shown from both a far away side perspective and the in-game perspective (with UI hidden).
Buoyant force and gravity implementation:

Knockback from riverbank collision implementation:

In my initial implementation of the river physics system, the normal vector of the river's plane couldn't change, so it was set to a fixed value at the start of each level. When I was done with implementing it, the level designers had gotten a bit further along in making levels. During their work they felt that the inability of changing the slope of the river was really limiting their ability to make exciting levels, so we rediscussed the feasibility of making the system able to handle the river's normal vector changing. I decided that it was feasable and that I was going to do it for two main reasons:
- I had finished the initially planned system quicker than expected.
- I had realized that this improvement wouldn't take as much work as earlier predicted. (The realization came while working on the first implementation.)
Making a change of normal vector possible required three things:
- Calculating the local normal vector at any position along the spline.
- Making the buoyancy and knockback work with any normal.
- Applying torque the boat to rotate it when the normal changes.
With the way I implemented the buoyancy and knockback, I got the second point on this list essentially for free. There were no problems from making the fixed value vector they used for projection change mid level. Calculating the local normal vector required complex vector geometry, with multiple cross products to get perpendicular vectors.
Figuring out how much torque and around what axis to apply it to get the boat to gradually change rotation without overshooting or entirely missing the normal was the hardest part. When I found the solution, it was surpisingly simple. The vector cross product is very often used in programming to get a direction perpendicular to two vectors, with the magnitude of the resulting vector discarded. But the magnitude of the cross product is exactly the torque resulting from a movement arm (one vector) being pulled by a force (the other). And when you continuously pull a movement arm toward a direction eventually it points in the direction of the pulling force. So the cross product of the up vector of the boat's transform and the local normal gives the needed torque.

The contents of the FixedUpdate function that runs every physics tick:

All downward velocity is deleted if the boat gets too deep underwater. This handles an edge case in the level design where in certain parts the boat plunges down into the water with high speed, making the boat go so deep that it's fully underwater for longer than the game designers wanted to allow it to. This is done instead of simply increasing the buoyant force, since that had the opposite issue of the boat getting bounced too high into the air, making the river seem like a trampoline.
The entire source code for the boat river physics can be viewed here.