A Simple Ball Game takes inspiration from Super Monkey Ball with all its precision platforming goodness. Features a set of versatile physics-driven gameplay systems, an addictive game loop, and seventy distinct and challenging levels.
Designed, prototyped, and implemented gameplay mechanics, systems, and levels. (physics-driven 3D gameplay systems, over eighty 3D levels, optional level interactions, progression and achievements, event-driven UI)
Collected, analyzed, and applied playtester feedback during iterative design process. (Managed regularly scheduled playtests over one year of development to improve gameplay feel and level design)
Designed, programmed, and modeled 3D meshes, blueprints, and post-processing stylization effects for world design and level building. (dynamic level building tools with variable construction patterns, material functions, synthetic fog, chromatic stylization, cel shaders, etc.)
Performed graphical optimizations to improve visual quality and maximize frame rate. (draw calls, lighting effects, reflections, tick based architectures, world shaders, etc.)
Handled various artistic tasks. (UI design and illustration, animation, music)
MY DESIGN PILLARS
Failure is part of the fun. Levels should encourage creativity and experimentation. When a player fails, they should be able to try a different approach. This is not a puzzle game, its a game with lots of open-ended challenges. Including checkpoints or shortcuts will give the player even more agency, reserved for only the most maddening levels.
Levels, like onions, have layers. Players should always feel like they're making steady progress towards completing a level, like they're slowly unraveling a mystery after each attempt. Stick to one completely unique precision or physics based gimmick/obstacle per level and separate each level into sections. For example, a single level could be split into three variations of the same gimmick, each more difficult than the last.
Camera initially spins around the level area. (late 2024)
Provide a quick overview of each level. (late 2024)
ENVIRONMENTS
Each environment I designed uses lighting, bloom, fog, chromatic-stylization, cell shaders, and other post-processing effects to create a distinct thematic scene.
Skyscraper. A light room theme, looking down on skyscrapers from above the clouds. 3D buildings were modeled within Unreal Engine.
Wireframe. A dark room theme making use of many bright neon lights ro create surface reflections.
Skyscraper. (current)
Wireframe. (current)
Satellite. A dark room theme set in space using a mix of lighting effects to simulate the sun, a big sphere mesh and local height fog to simulate a planet's atmosphere, and an even bigger sphere to create a sea of stars.
Slate. A light room theme using fog, lighting effects, reflections, and shadows to create a washed-out surreal environment. Created many 3D building variations using Unreal's in-engine modeling.
Satellite. (current)
Slate. (current)
INTERACTIVE ELEMENTS
In addition to platforms and moving obstacles, levels feature various interactive elements. (current)
Goals. One or more per level. Reaching one of these, ends the current challenge and progresses the player to the next one. Certain special goals will lead to a different challenge.
Artifacts. One per level. An optional collectable. Collecting all artifacts will unlock a set of extra challenging levels at the end of the gauntlet.
Switches. These create permanent effects that persist even after the player falls out. Sometimes used as a sort of checkpoint.
Dash Pads. Adds a large amount of velocity to the ball in a certain direction. Sometimes helpful, sometimes not.
Simple example of a switch in-game. (current)
Level using the satellite theme. (current)
LOTS AND LOTS OF LEVELS
Currently, there are 70 playable levels in A Simple Ball Game. In total, I designed somewhere around 83. Many have gone through reworks over the past months in response to playtests, player feedback, and data analysis.
Levels are split into different difficulties, Simple, Difficult, and Impossible. Here are some examples:
Simple 1, the very first level the player will encounter. (current)
Difficult 15, ride on top of platforms the rocket across from one side to the other. (late 2024)
Impossible 25, a complex maze full of switches that change its structure. (current)
Difficult Ex. 3, hug the wall to avoid falling into space. (current)
Impossible 8, swaying platforms get faster and faster the closer you get to the goal. (late 2024)
Difficult 10, with well timed maneuvers, move between the large rotating walls. (late 2024)
Impossible 29, the entire structure rotates 90 degrees every few seconds. (late 2024)
Difficult Ex. 5, jump from moving platform to moving platform as you make you way out. (late 2024)
The practice menu, where the player can improve their skills by trying any levels they have unlocked. (current)
WHAT GOES INTO A LEVEL REWORK?
After collecting data, watching playthroughs, and reviewing feedback, I decide if a level is problematic enough to need changing.
During a recent playtest, a level in that version caused a much larger percentage of players than anticipated to stop playing.
Initially, it featured three spinning rings positioned horizontally, each slightly lower with a smaller radius than the last. The challenge was to maneuver the ball across each ring before reaching the goal.
I removed the hight difference between each ring. Instead of dropping onto subsequent rings, players can just roll onto them. I did this to improve visibility and focus the physics property I wanted to present.
I added a secondary ring inside of each outer ring that rotates in the opposite direction. I found that players rarely fell off the level towards the inside portion of the rings because the centrifugal force would push them outward. Now, players can stick to the outer ring, use a combination of the outer and inner rings, or stick to the new inner ring.
I added straight pathways inside each inner ring that rotate in the same direction as the outer ring. These straight platforms are animated in such a way, allowing players with perfect timing to quickly roll across all of them straight to the goal.
MY DESIGN PILLARS
Simple to learn, hard to master. The control should be as simple as possible, only moving the left stick for controller or the mouse for mouse & keyboard. Difficulty should only come from the level design.
Full control, no input lag. When the player moves the control stick, the ball moves. Not after a couple frames... RIGHT FREAKING NOW! Also, the ball should move exactly how the player wants it to move.
The most versatile camera ever made and it should live in the subconscious. The camera should constantly adjust its orientation to match the "intention" of the ball. Not where the ball is, but where its going. The player should never need to think about the camera.
Complexity is restrained to the level design. (late 2024)
With precise movements, players can navigate a wide array of structures. (late 2024)
NOW FOR THE COMPLICATED PART...
While prototyping, I decided on this relationship for scene components:
Moving the Control Stick causes the Level to Tilt.
The Ball is the only object affected by the Physics Engine (Gravity), so when the Level Tilts the Ball moves.
The Camera follows the Velocity Vector of the Ball (ball moves left, camera faces left).
The Level Tilt is adjusted to match the Orientation of the Camera.
Some Examples:
Camera faces North (+Y), and the Control Stick moves North (+Y), the Level will Tilt North (+Y).
Camera faces North (+Y), and the Control Stick moves West (-X), the Level will Tilt West (-X).
Camera faces East (+X), and the Control Stick moves East (+X), the Level will Tilt South (-Y).
Camera faces South (-Y), and the Control Stick moves South (-Y), the Level will Tilt North (+Y).
In other words, I need to set up something like this: camera direction + control stick direction = level tilt direction. Here are a few examples of the prototype in action.
Early prototype gameplay. (~April 2024)
Late prototype gameplay. (~May 2024)
THE CAMERA... SO, HOW DOES IT WORK?
The camera is attached, as a child component, to a Spring Arm that guarantees the camera maintain a set distance away from the ball at all time. There are two components to its rotation:
Y Rotation (Pitch). This is determined using the normal (ramp angle) of the platform directly below the ball.
Z Rotation (Yaw). This is determined using the velocity vector of the ball.
We are only concerned with the pitch and yaw in this case.
Normal: the angle of the ramp directly below the ball.
An object's velocity vector needs to be split into X and Y components.
Only the Spring Arm's Z Rotation (its yaw) will be defined.
The Spring Arm's Pitch (Y) adjusts when the ball rolls down a ramp. (late 2024)
The Spring Arm's Yaw (Z) continuously updates as the ball's velocity changes. (late 2024)
Let's setup a few variables first: The ball's directional velocity, and the Spring Arm's Z rotation.
BallDirectionalVelocity = Arctan2 ( ballVelocityY / ballVelocityX )
Returns a value between (-180, 180) inclusive.
0 = forward, 90 = right, -90 = left, -180 and 180 = backwards
currentCameraRotationZ = SpringArm->RotationZ(Yaw)
Returns a value between (-180, 180) inclusive.
0 = forward, 90 = right, -90 = left, -180 and 180 = backwards
BallDirectionalVelocity (blueprint)
currentCameraRotationZ (blueprint)
Next we calculate a few distances, this way we know the direction the camera should rotate. We prefer whichever distance is shorter.
NormalDistance = ABS( ( BallDirectionalVelocity + 180 ) - ( currentCameraRotationZ + 180 ) )
The distance between currentCameraRotationZ and BallDirectionalVelocity, not passing through the -180 to 180 gap.
OppositeDistance = ( 180 - ABS( currentCameraRotationZ ) ) + ( 180 - ABS( BallDirectionalVelocity ) )
The distance between currentCameraRotationZ and BallDirectionalVelocity, passing through the -180 to 180 gap.
NormalDistance (blueprint)
OppositeDistance (blueprint)
Using the distances and applying some logic, we'll know what the camera's new rotation should be. However, this value is not set directly as that would cause the camera to jump around sporadically. It's approached by adding a fraction of the distance.
IF BallDirectionalVelocity < 0 AND currentCameraRotationZ >= 0 AND OppositeDistance < NormalDistance
CameraRotationZ += OppositeDistance / 45
ELSE IF BallDirectionalVelocity >= 0 AND currentCameraRotationZ < 0 AND OppositeDistance < NormalDistance
CameraRotationZ += -OppositeDistance / 45
ELSE
CameraRotationZ += ( BallDirectionalVelocity - currentCameraRotationZ ) / 45
The full blueprint, incorporating previous logic.
LEVEL TILT... NOW WHAT?
TLDR: Level Tilt = ControlStickOrientation(X, Y) + CameraRotationZ
Let's start by defining the control stick orientations like this:
Pushed NORTH (up) Y = 1 X = 0
Pushed SOUTH (down) Y = -1 X = 0
Pushed WEST (left) Y = 0 X = -1
Pushed EAST (right) Y = 0 X = 1
Control Stick Orientation determines Level Tilt. (late 2024)
CameraRotationZ adjusts direction. (late 2024)
First, we define variables:
The X and Y control stick orientations were defined above as values between -1 and 1.
cameraZ = CameraRotationZ / 90
The camera's rotation needs to be adjusted as the value of CameraRotationZ lies between -180 and 180. Dividing by 90 will result in a value between -2 and 2. This will make things a bit easier.
0 = forward, 1 = right, -1 = left, -2 and 2 = backwards
cameraZ (blueprint)
Next, we need to find the CONTRIBUTION the X and Y control stick orientations have on the level tilt factoring in cameraZ.
Discussion:
When the camera is facing north and we want to tilt the level north, we must move the control stick north (up). This means that the CONTRIBUTION of the Y component of the control stick on the Y tilt of the level is 100%. The level tilt exactly matches the Y orientation of the control stick.
On the other hand, no matter how much we adjust the X orientation of the control stick (moving it east or west), the level will never tilt forward, meaning that the CONTRIBUTION of the X orientation of the control stick on the Y tilt of the level is 0%.
Now, what happens when the camera rotates 90 degrees to the east? When the camera is facing east, and we want to tilt the level north—same as before—we must move the control stick to the west (left) instead to account for the camera's rotation. Now, the CONTRIBUTION of the X orientation of the control stick on the Y tilt of the level is 100 and the CONTRIBUTION of the Y orientation of the control stick on the Y tilt of the level is 0%.
Now to represent this relationship with equations:
contributionX_axis = controlStickOrientationX * -( ABS( cameraZ ) - 1 )
contributionY_axis = controlStickOrientationY * ( -1 * cameraZ / ABS( cameraZ ) ) * ( ABS( ABS( cameraZ ) - 1 ) - 1 )
NewLevelTilt_X = contributionX_axis + contributionY_axis
LevelTilt_X. [ controlStickOrientationX = X Input, controlStickOrientationY = Y Input ] (blueprint)
contributionX_axis = controlStickOrientationY * -( ABS( cameraZ ) - 1 )
contributionY_axis = controlStickOrientationX * ( cameraZ / ABS( cameraZ ) ) * ( ABS( ABS( cameraZ ) - 1 ) - 1 )
NewLevelTilt_Y = contributionX_axis + contributionY_axis
LevelTilt_Y. [ controlStickOrientationX = X Input, controlStickOrientationY = Y Input ] (blueprint)
Recent-ish gameplay demonstration of gameplay mechanics. (~October 2024)
Finally, let's smooth out the tilt so the level doesn't sporadically jump around. We need to define a few variables:
MAX_LEVEL_TILT
Defines the maximum tilt the level can achieve. If the control stick is pushed all the way in one direction, this is how much the level will tilt before stoping. Constant.
TILT_SMOOTHER
Used to smooth out the level's tilt each frame. The higher the value, the less the level will tilt each frame. Constant.
previousLevelTilt_X and previousLevelTilt_Y
The level's X and Y tilt from the previous frame.
We use these next equations to figure out what the level's final tilt should be each frame:
LevelTiltX_Adjusted = ( ( NewLevelTilt_X - previousLevelTilt_X ) / TILT_SMOOTHER ) + previousLevelTilt_X
LevelTiltY_Adjusted = ( ( NewLevelTilt_Y - previousLevelTilt_Y ) / TILT_SMOOTHER ) + previousLevelTilt_Y
FinalLevelTilt_X = previousLevelTilt_X + MIN( LevelTiltX_Adjusted, MAX_LEVEL_TILT )
FinalLevelTilt_Y = previousLevelTilt_Y + MIN( LevelTiltY_Adjusted, MAX_LEVEL_TILT )
The full blueprint, incorporating previous logic.
If you've made it this far, I just want to let you know how awesome you are!