Notes on the Making Of Ping Pong Color
2022-05-19, Game DesignAbove is the trailer I've made for my first (proper) game, Ping Pong Color. After spending a few years by learning Unity and C# with online tutorials I've decided to embark in this small project that took about 5-6 weeks of development.
Disclaimer
As mentioned, this is my first game, plus, the purpose of this article is for me to document and write about my experience developing this game. You might witness some code gore and other atrocities, if you get something useful from this article that's awesome, if you want to share with me a better way to make something shown in this article (as that would be useful for future projects), feel free to send me an e-mail or DM, thanks!
On Gameplay
Ping Pong Color is a simple arcade game where your objective is to advance in an infinite level while you destroy everything in your path for a higher score. The colors are in sync with the music.
If the ball and the obstacle are the same color when they collide, you destroy the obstacle, if not, you lose.
Input Systems
PC
On the PC version, you have a circle (called 'Paddle') that you move around with the mouse to hit the ball.
This paddle has a trigger attached to it to detect when it hits the ball, then, based on the velocity and direction of the paddle, physics are applied to the ball.
A highly compressed GIF showing the input system on PC
Initially, the paddle was supposed to be a simple collider with a bounce material attached to it, this however became an issue as it meant you can literally 'push' the ball around the screen, which wasn't desired.
Mobile
The PC input system would've made the game a hell to play on mobile devices for a multitude of reasons:
- Moving a paddle around a tiny screen would be tedious and imprecise.
- You would cover a big portion of the screen with your finger.
- Unless you have something like a matte screen protector, you finger wouldn't move properly throughout the mobile screen, which would be annoying.
For these main reasons, another input system was designed exclusively for mobile devices (and it's more fun than the PC input)
On mobile you just use a finger (quite possible your thumb, if you're grabbing the phone with two hands) anywhere on the screen to swipe on the direction you want to hit the ball. This also considers the speed at which you swipe to apply the proper physics to the ball.
On Level Generation
Ping Pong Color generates an infinite level when you start playing in a simple manner:
- We have pre-built blocks that contains multiple elements
- Each block calculates its bounding box
- We spawn a 'batch' of blocks
- We spawn one block (selecting a random one from an array)
- Next block is spawned at the end position of the previous block plus a defined offset
- When the ball gets close to the end of the last created batch, we just spawn another batch.
Blocks
As of time of writing this, there are 100 hand-made blocks, lots of them 'small' variations of others. These blocks are by themselves built by objects such as 'Moving Spikes', 'Black Holes', 'Pick-ups'
Examples of some blocks used in the level generation
Bounding Box Generation
Above is an example of the bounding boxes generated by the script below
// added to every block
public class BoundingBox : MonoBehaviour {
public Bounds bounds;
[HideInInspector] public Vector2 offsetToMin;
List<Collider2D> allColliders = new List<Collider2D>();
List<Transform> allTransforms = new List<Transform>();
float boxSizeX;
void Awake() => GetBoundingBox();
public void GetBoundingBox() {
GetAllColliders(transform);
GetAllTransforms(transform);
CalculateBoundingBox();
// An offset from the origin of the block to the min point of the bounds
// Used in the level generator to avoid overlapping when placing a new block
offsetToMin = transform.position - bounds.min;
}
// We add all the colliders of the block recursively to allColliders
void GetAllColliders(Transform trans) {
foreach (Transform t in trans) {
Collider2D collider = t.GetComponent<Collider2D>();
if (collider != null)
allColliders.Add(collider);
GetAllColliders(t);
}
}
// We add all the transforms of the block recursively to allTransforms
void GetAllTransforms(Transform trans) {
foreach(Transform t in trans) {
allTransforms.Add(t);
GetAllTransforms(t);
}
}
void CalculateBoundingBox() {
// we make use of the provided Bounds() class by Unity
bounds = new Bounds(transform.position, Vector3.zero);
// We add all the transforms to the bounds
foreach (Transform t in allTransforms)
bounds.Encapsulate(t.position);
// We add the min and max positions of the colliders to the bounds
foreach (Collider2D col in allColliders) {
bounds.Encapsulate(col.bounds.extents + col.bounds.center);
bounds.Encapsulate(-col.bounds.extents + col.bounds.center);
}
// we only care about the x-size of the bounding box (as the level is horizontal)
boxSizeX = bounds.size.x;
}
}
Sometimes, empty game objects are used to add a manual margin to specific blocks
A big lesson was learned when trying to get the level generation to work: If your code is getting messier and messier and still doesn't work, you're probably missing something essential (in my case, some basic vector addition and subtraction)
On Visuals
The art style is just basic shapes. Cubes, triangles and circles (I just really like Suprematism.)
Most of the 'interesting' visual stuff on the game comes from:
- Some post-processing.
- The possibility to choose between color palettes.
- Some shadery stuff.
UI
Making the UI in Unity was, naturally, painful. Hopefully the new UI Builder (that looks a lot nicer) will relieve that pain a bit in future projects.
The most complex shape in all of Ping Pong Color might be the cog wheel in the main menu (unless we count text?).
The main menu includes:
- The logo
- Some buttons below the name
- Leaderboard
- Play
- Settings
- The last score on the top right
- The credits on the bottom right
- Quit button
This is the settings menu for PC
It doesn't include much out of the ordinary, some stuff:
- A sound is played for reference when you modify the sound of SFX, FMOD allows you to add a 'cooldown' so it doesn't trigger every frame
- The color selector randomly sets a color from the palette to each character.
- For this you need to iterate through each character, then through each vertex's color of the 'quad' containing the character. I set the same color for each vertex, but you can set a random per vertex and you will get some pretty gradients for free :)
TMP_TextInfo textInfo = colorText.textInfo;
Color32[] vertexColors = textInfo.meshInfo[0].colors32;
Color prevColor = new Color(0, 0, 0, 0);
for(int i = 0; i < textInfo.characterCount; i++) {
int vertexIndex = textInfo.characterInfo[i].vertexIndex;
Color randomColor;
do randomColor = ColorManager.Instance.GetRandomColor().color;
while (randomColor == prevColor);
prevColor = randomColor;
vertexColors[vertexIndex + 0] = randomColor;
vertexColors[vertexIndex + 1] = randomColor;
vertexColors[vertexIndex + 2] = randomColor;
vertexColors[vertexIndex + 3] = randomColor;
}
colorText.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32);
Palette System
Palettes are implemented with ScriptableObjects, here's an example of how they look like in the Inspector:
The Colors array is an array of a custom struct that includes a Color and a string to identify the color with a name. This string is used for comparisons in other places of the game.
Color Blind Palette
I've made a color-blind friendly palette (don't trust me on this, haven't actually tested it with color-blind people) out of curiosity.
I've done a tiny bit of research and found this excellent GDC talk and this amazing resource that lets you upload an image and see how someone with certain type of color blindness would see it.
As far as I understand, deuteranomaly is the most common type, so I mainly focused on the palette to work for that type (it works better and worse in other types, feel free to upload a screenshot of the game to the color-blind simulator and see!)
(If you're not color-blind) this is what you would see normally:
And this is what someone with deuteranomaly would see instead:
As a rule of thumb: If you can distinguish the colors in black and white, you're probably good to go!
The Black Hole
I took a shader code somewhere from the internet and I fiddle with it a bit.
This is the shader code used in my game:
sampler2D _MainTex; // texture from the camera
uniform float2 _Position; // position of the black hole in screen space
uniform float _Radius; // radius of the black hole
fixed4 frag (v2f i) : SV_Target{
float ratio = _ScreenParams.x / _ScreenParams.y; // gets screen ratio
fixed2 pos = _Position / _ScreenParams; // idk
float2 offset = pos - i.uv; // don't remember
float rad = length (offset);
float intensity = sin (_Time.y * 1.5);
float deformation = 1 / pow (rad, abs(intensity)) * _Radius * intensity; // certainly idk
offset.x *= ratio;
offset = i.uv + normalize (offset) * 0.001; // absolutely no idea
offset = offset * (1 - deformation);
return tex2D (_MainTex, offset);
}
This is the code in charge of sending the data to the shader:
public Gravitor gravitor;
public Material mat;
float radius;
void Start() { radius = gravitor.range; }
void OnRenderImage(RenderTexture source, RenderTexture destination) {
if (gravitor != null) {
mat.SetVector("_Position", gravitor.screenPos);
mat.SetFloat("_Radius", radius * gravitor.effectIntensity);
Graphics.Blit(source, destination, mat);
} else
Destroy(this);
}
In the Gravitor (A.K.A Black Hole) game object, I add the code above when a Gravitor is created.
void Awake() {
blackHole = Camera.main.gameObject.AddComponent<BlackHoleEffect>();
blackHole.gravitor = this;
blackHole.mat = blackHoleMat;
}
void Update() {
// This code disables the black hole if it's outside the camera's view
screenPos = mainCamera.WorldToScreenPoint(transform.position);
blackHole.enabled = !(Mathf.Abs(screenPos.x) > Screen.width || Mathf.Abs(screenPos.y) > Screen.height);
}
Because the black hole shader acts as a post-processing effect, the UI gets distorted by it!
On Sound
What I'm certainly not is a sound expert. My knowledge on making music is not much, and I've tried to quickly learn FMOD and Reaper in a few days for this project.
I've chosen FMOD over Wwise because it looks better
Above is what the music from Ping Pong Color looks like in Reaper, it's quite cluttery, but I'm happy with the result. It's divided into different sections that correspond to a Game State.
I could've reduced the amount of sections in the Reaper project if I knew FMOD, but I "learned" FMOD after the song was done :(
Inside the Unity project I have an Enum that keeps track of the current state of the game, this is linked to the GameState parameter in the FMOD project. When the state changes in-game, FMOD takes care of transitioning to the proper section of the song.
On Mobile Optimization
I haven't studied a lot about optimization and profiling in Unity, however I've found some ways to optimize the game to work properly on mobile devices, specially low-end devices.
Fixed DPI
Under the Resolution and Presentation settings of the Project Settings you can find the option to force the rendering to a specific DPI, meaning that you will (probably) not render at full resolution unnecessarily. In this case I went with 280 Dots per Inch. This improved performance on mobile by a lot.
Performance Mode
Another thing I've did was to add an extra option to the settings menu.
This toggle is linked to the script below, which disables all post-processing effects except for the Lens Distortion.
public void SetPerformance(bool performance) {
isPerformance = performance; // why TF did I do that???
PostProcessProfile profile = postFxVolume.sharedProfile;
foreach (PostProcessEffectSettings setting in profile.settings)
if (setting.GetType().Equals(typeof(LensDistortion)))
return;
else
setting.active = !isPerformance;
}
On Publishing
I haven't put much effort on publishing or in marketing this game. Probably this section will be updated if I decide to publish on Steam, iOS, or Android. Right now the game is available for free on itch.io.
- I want to publish it on Play Store
- Google doesn't accept pre-paid cards to pay the $25 Play Store fee, so I need to get a credit card for this.
- I want to publish it on iOS/iPadOS/macOS
- I don't have a Mac nor an iPhone.
- I want to publish it on Steam
- I need to pay the $100 fee.
- I need to learn the SteamWorks API and integrate it to the game.
- Add some achievements and trading cards.
The summary is that, even without this game being a 'success' (as in, making money from it), what I've got from making it was extremely valuable.
- It made me feel accomplished, after years of learning game development without actually making a game of my own.
- I learned a lot of stuff from it, stuff that generally tutorials don't or can't teach you, mainly problem solving (lots of problem solving...)
- It makes for a great portfolio piece that might potentially get you a job in the industry.
- It was fun doing it :)
My suggestion, if you already know the basics (be it, Unity, Unreal, Godot, some basic vector/trigonometry math) just make a little project on your own. It might take a week, or a month, it might never get finished. But you will learn a lot from it.
Hope you enjoyed the article! For any comments, here's my email: simonsanchez.art@gmail.com
~ I'm currently working on my second game, wish me luck :)