Totes Tubular

Splash down a lazy river with jumps, loops and obstacles in Totes Tubular, a 3D hyper-casual endless runner. Avoid spiky mines that deflate your tube, and race towards the end of each water track section to patch your tube and pump it back up.

Summary of responsibilities

  • Developed core gameplay concept and design

  • Primary programmer for all game features

  • Spearheaded UI setup and animation


Detailed Breakdown

After the release of Tank Party, hyper-casual games had slowly flooded the mobile market and become popular due to the genre’s simple, yet addictive, gameplay. While the rest of the GameResort team began working on Stupid Zombies 4, I was tasked with prototyping a minimalist, endless runner to attempt to break into the hyper-casual market. The goals for this project were to:

  • Develop a unique game with an engaging theme.

  • Design the gameplay to be as simple as possible.

I prototyped a few concepts until we decided on a tubing theme, which in part was inspired by my recent vacation to Kauai, where I tubed down a lazy river through an old sugar plantation irrigation system. 

After several months of development, we released Totes Tubular (TT) in soft launch. Unfortunately, the retention numbers weren’t strong enough to warrant further development, and since by this point the rest of the team had finished Stupid Zombies 4, the project was shelved. Despite this, there were a few interesting tools and methods that I developed worth discussing in greater detail.


Tracks

I used Bi-Arc splines -- one of my favorite tools -- for the tracks in TT. I discovered this type of spline years ago on Ryan Juckett’s website and implemented them in Unity. I suggest that you read his example for a detailed breakdown that includes code; the basic premise is that each spline section is defined by two circular arcs that connect at a matching tangent.

 
Example of 2D arc spline segment with circles shown.

Example of 2D arc spline segment with circles shown.

 

Bi-Arc splines have several advantages over more traditional Bezier curves or Catmull-Rom splines:

  • Ability to easily space points evenly on the curve or move an exact distance along the curve without having to integrate.

  • It is possible to quickly find the closest point on the curve to any other point in space.

  • A specific rotation can be set for each point along the curve.

These properties were what made Bi-Arc splines the ideal choice for developing track segments in TT. 

TrackSegment_0.jpg
Add caption here.

The tracks in TT are composed of several unique segments defined using these curves, attached end to end in a chain. The player component keeps a reference to the current track segment they are on, and each frame queries that track segment for the player’s next position, based on their velocity and the elapsed frame time.

public enum WaterTrackGetInfoResultType { None, OnTrack, OffFront, OffLeft, OffRight } public struct TrackInfo { public Vector2 trackPosition; //X is horizontal position on track, Y is distance along track public Vector3 position; public Quaternion rotation; public TrackCollisionInfo collisionInfo; } public struct WaterTrackGetInfoResult { public static readonly WaterTrackGetInfoResult defaultResult = new WaterTrackGetInfoResult() { track = null, type = WaterTrackGetInfoResultType.None }; public WaterTrack track; public TrackInfo trackInfo; public WaterTrackGetInfoResultType type; } trackResult = track.GetTrackInfo(trackResult.trackInfo.trackPosition + move, radius);

During gameplay, players move seamlessly between track segments. Behind the scenes, each track segment holds a reference to the next segment in the chain. If the player component asks for a position beyond the end of its current track segment, it returns the next segment in the chain, as well as the equivalent position on it. Once the player reaches the end of a segment chain, the tube flies through the air to the next section.

//If the distance is longer than the spline, the final point's position and rotation are returned ArcSplinePathInfo info = _path.GetInfo(trackPosition.y); if (trackPosition.y > _path.distance) { trackPosition.y -= _path.distance; if(_hasNextTrack) { return nextTrack.GetTrackInfo(trackPosition, radius); } return new WaterTrackGetInfoResult() { track = null, type = WaterTrackGetInfoResultType.OffFront, trackInfo = new TrackInfo() { trackPosition = trackPosition, position = info.position + (info.rotation * new Vector3(0.0f, 0.0f, trackPosition.y)), rotation = info.rotation, collisionInfo = TrackCollisionInfo.none } }; }

This system is also used by the camera, which queries the player component’s current track segment for a position in the future, using this information to frame both the player and the upcoming track.

Vector2 lookAheadPosition = new Vector2(tubeTrackPosition.x, tubeTrackPosition.y + (_lookAheadTime * tubeVelocity); WaterTrackGetInfoResult predictedTrackResult = _tube.trackResult.track.GetTrackInfo(lookAheadPosition, 0.0f);

These queries also return the rotation of the track, which is used to determine not only the orientation of the player, but also the velocity at which they should be moving.

float verticalDot = Vector3.Dot(trackInfo.rotation * Vector3.forward, Vector3.down) * 0.5f + 0.5f; //A physically correct approach here would be to multiply the dot product by gravity. //However, we don’t want the tube to ever go backwards so we just reduce acceleration to some minimum value when heading upwards float accelerationThisFrame = Mathf.Lerp(_minAcceleration, _maxAcceleration, verticalDot);

Each segment also defines a start and end width for the track, which is used by the player to check if they are colliding with the track sides.

float width = Mathf.Lerp(_startWidth, _endWidth, trackPosition.y / _path.distance) - tubeRadius; localTrackPosition.x = Mathf.Clamp(localTrackPosition.x, -width, width);

All of the meshes were generated for each track segment by extruding a hand-authored mesh along the length of the spline. A water mesh was also generated for each track segment.


EFFECTS

For the water, I referenced Unity’s recently released “Boat Attack” demo. This project features excellent water effects and was designed for mobile, so it seemed a logical place to start.

Unity “Boat Attack” demo running at approximately 5 frames per second on Android.

Unity “Boat Attack” demo running at approximately 5 frames per second on Android.

Boat attack was written using an implementation of Gerstener waves. This technique displaces vertices in horizontal and vertical directions based on the wave’s orientation. The boat attack demo was written with the assumption of a world space y-up orientation, a reasonable assumption to make about water under most circumstances. For TT, I needed to apply the water to tracks that could orient in any direction. To achieve this, I converted the sample to use the mesh normal as the up direction, with the tangent and bi-tangent as the x and z instead, which was relatively straightforward.

//Sample waves taken from boat attack, wave world position encoded in uv x and y of mesh. //Mesh colors used to adjust horizontal and vertical strength of waves at track corners WaveStruct wave; SampleWaves(float3(input.uv.x, 0.0f, input.uv.y), half3(input.color.a, input.color.b, input.color.a), wave); half3x3 normalMatrix = half3x3(normalInputs.tangentWS.xyz, normalInputs.bitangentWS.xyz, normalInputs.normalWS.xyz); input.normalWS.xyz = TransformTangentToWorld(wave.normal.xyz, normalMatrix);

The boat attack demo also featured caustics, which were faded in based on a given height in the y axis at any point. Although the caustics were later removed, I converted this as well, baking the water height into the vertex colors of the generated track. The effect can be seen in an early prototype video.

The Gerstener waves also had a CPU implementation that was used to create the tube physics. The tube used 8 sample points in a ring to sample the waves around it. If the point was below the water, I used a spring equation to calculate the velocity depending on how displaced it was from the surface. If it was above the water, gravity was applied. The position and rotation of the tube were then created, based on these 8 samples each frame.

 
Gizmos showing wave sample point placement for tube.

Gizmos showing wave sample point placement for tube.

 

Additional effects that I included were applying rotational velocity to the tube when colliding with obstacles or walls, as well as mesh deformation. Combined, these effects achieved my desired aesthetic of careening down a water track in a tube.

Previous
Previous

Doom-Style Level Editor