A Circle Pong Game

A quick and simple Circle Pong game, build in Unity, with bits of Reactive Extensions… It can serve as the basis of a more complex puzzle/arcade type of game. In the game you must keep the bouncing ball inside the circle, by rotating the circular paddle which grows smaller with every hit.

The Game View

I build a texture for each of the 16 arcs which made up the circle. The result wasn’t the best, and the circle looks wobbly. I’d recommend using either one carefully drawn texture and then rotate it to form the circle, or even better, draw the arcs with bezier curves.

The game view looks like this before running the game:

The structure of each arc:

The structure of the ball:

The circle test script attached to GameView:

The Code

Inside the start method I create collision observables for each arc.

1
2
3
4
5
6
7
8
9
10
arcColors = new int[arcs.Length];
 
var i = 0;
while (i < arcs.Length) {
	arcs[i].OnCollisionEnter2DAsObservable ()
	.Where (x => x.gameObject.tag == "Ball" )
	.Subscribe (x => OnCollision() );
	arcColors [i] = 0;
	i++;
}

Then comes the game update observable which I use for touches and mouse input based on the target.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
var gameUpdate = Observable.EveryUpdate ();
 
#if !UNITY_EDITOR
var touches = gameUpdate
	.Subscribe (_ => {
		if (Input.touchCount > 0) {
			switch (Input.touches[0].phase) {
			case TouchPhase.Began:
				OnTouchDown(Input.touches[0].position);
				break;
			case TouchPhase.Ended:
			case TouchPhase.Canceled:
				OnTouchRelease();
				break;
			default:
				OnTouchMove(Input.touches[0].position);
				break;
			}
		}
	});
#else
var mouseDown = gameUpdate
	.Where(_ => Input.GetMouseButtonDown(0))
	.Subscribe ( _ => { 
		if (!touchDown) {
			OnTouchDown(Input.mousePosition);
		}
	});
 
var mouseMove = gameUpdate
	.Where (_=> touchDown == true)
	.Subscribe(_ => OnTouchMove(Input.mousePosition));
 
var mouseUp = gameUpdate
	.Where( _ => Input.GetMouseButtonUp(0))
	.Subscribe (_ => {
		OnTouchRelease();
	});
 
#endif

Then an observable for when the ball is outside the circle (game over). Here you would need to switch off observables by cancelling out of them, if you wish.

1
2
3
4
var outofbounds = gameUpdate
	.Select (_ => ball.transform.position)
	.Where (b => b.x < -3 || b.x > 3 || b.y < -4.3f || b.y > 4.3f)
	.Subscribe (_ => Debug.Log ("GameOver")); //cancel out of subscribed observers here!

Next I set the ball movement and create the first paddle with ShowArcs()

The game can be made a lot harder by changing the ball speed. You could add logic that speeds up the ball after each collision.

1
2
3
4
var rb = ball.GetComponent<Rigidbody2D> ();
rb.velocity = new Vector2 (0, -3);
 
ShowArcs ();

I chose to color the paddle arc with a different color each time there’s a collision.

This logic could be used to change the color of the ball, and after spawning multiple paddles, the player has to match the color of ball and paddle.

Anyway, the ShowArcs logic creates a randomly placed paddle, and sets its color.

The maximum numbers of arcs used to create the paddle decreases with each collision, which results in a smaller paddle.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void ShowArcs () {
 
	foreach (var a in arcs) a.SetActive(false);
 
	var index = Random.Range(0, arcs.Length);
	var direction = Random.Range(0, 10) > 5 ? -1 : 1;
	var i = 0;
	var color = gameColors[ Random.Range(0, gameColors.Length)];
	var arcsTotal = Random.Range(minArc, maxArc);
 
	while (i < arcsTotal) {
		arcs[index].SetActive(true);
		arcs[index].GetComponent<SpriteRenderer>().color = color;
		index += direction;
 
		if (index < 0) index = arcs.Length - 1;
		if (index == arcs.Length) index = 0;
		i++;
	}
}

The collision logic: it reduces the size of the next paddle and then builds it.

1
2
3
4
5
6
7
8
9
10
void OnCollision () {
 
	minArc--;
	maxArc--;
 
	if (minArc == 0) minArc = 1;
	if (maxArc == 0) maxArc = 1;
 
	ShowArcs();
}

The Input Events

Touch Down stores the current values for the circle, its rotation as well as the original angle formed between the touch position and the circle’s center.

1
2
3
4
5
6
7
8
9
10
11
12
public void OnTouchDown (Vector3 t)	{
 
	var touch = Camera.main.ScreenToWorldPoint (t);
 
	rotationSpeed = 0;
	circleRotation = gameObject.transform.eulerAngles;
 
	float angle = Mathf.Atan2 (touch.y - origin.y, touch.x - origin.x);
	originalAngle = 180 * ( angle / Mathf.PI );
 
	touchDown = true;
}

You’ll notice that I use a lot of data outside the methods, AKA side effects. I could rewrite the logic so everything is self contained in composed observables. But usually I do a first “draft” of the code with side effects and then have fun getting rid of them.

You should try it as an exercise, it’s the best way to learn to use Reactive Extensions.

Touch Release simply changes the touchDown state.

1
2
3
public void OnTouchRelease () {
	touchDown = false;
}

The magic happens in TouchMove:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void OnTouchMove (Vector3 t) {
 
	if (!touchDown)
		return;
 
	var touch = Camera.main.ScreenToWorldPoint (t);
 
	var rotationNow = gameObject.transform.eulerAngles;
 
	float angle = Mathf.Atan2 (touch.y - origin.y, touch.x - origin.x) ;
	float rotation = 180 * ( angle / Mathf.PI );
 
	Vector3 newRotation = circleRotation;
	newRotation.z += (rotation - originalAngle );
 
	if (newRotation.z >  rotationNow.z + 180) newRotation.z -= 360;
	if (newRotation.z <  rotationNow.z - 180) newRotation.z += 360;
 
	rotationSpeed = (newRotation.z - rotationNow.z) * 0.2f;
 
	rotationNow.z += rotationSpeed;
	gameObject.transform.eulerAngles = rotationNow;
}

The logic allows for different rotation speeds which can also make the game harder, and it wouldn’t be too hard to add a spring effect to the rotation.

And that’s it.

You can download the source here.

The Complete Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
using UnityEngine;
using System.Collections;
using UniRx;
using UniRx.Triggers;
 
 
public class CircleTest : MonoBehaviour {
 
	public GameObject[] arcs;
	public GameObject ball;
 
	private Vector3 circleRotation;
	private Vector3 origin = new Vector3 (0,0,1);
	private bool touchDown = false;
	private float originalAngle = 0.0f;
	private float rotationSpeed = 0;
 
	private int[] arcColors;
 
	private int minArc = 10;
	private int maxArc = 14;
 
	private Color[] gameColors = new Color[] { 
		Color.blue, Color.green, Color.gray, Color.yellow, Color.red, Color.magenta, Color.cyan
	};
 
	void Start () {
 
		arcColors = new int[arcs.Length];
 
		var i = 0;
		while (i < arcs.Length) {
			arcs[i].OnCollisionEnter2DAsObservable ()
			.Where (x => x.gameObject.tag == "Ball" )
			.Subscribe (x => OnCollision() );
			i++;
		}
		var gameUpdate = Observable.EveryUpdate ();
 
		#if !UNITY_EDITOR
		var touches = gameUpdate
			.Subscribe (_ => {
				if (Input.touchCount > 0) {
					switch (Input.touches[0].phase) {
					case TouchPhase.Began:
						OnTouchDown(Input.touches[0].position);
						break;
					case TouchPhase.Ended:
					case TouchPhase.Canceled:
						OnTouchRelease();
						break;
					default:
						OnTouchMove(Input.touches[0].position);
						break;
					}
				}
			});
		#else
		var mouseDown = gameUpdate
			.Where(_ => Input.GetMouseButtonDown(0))
			.Subscribe ( _ => { 
				if (!touchDown) {
					OnTouchDown(Input.mousePosition);
				}
			});
 
		var mouseMove = gameUpdate
			.Where (_=> touchDown == true)
			.Subscribe(_ => OnTouchMove(Input.mousePosition));
 
		var mouseUp = gameUpdate
			.Where( _ => Input.GetMouseButtonUp(0))
			.Subscribe (_ => {
				OnTouchRelease();
			});
 
		#endif 
 
		var outofbounds = gameUpdate
			.Select (_ => ball.transform.position)
			.Where (b => b.x < -3 || b.x > 3 || b.y < -4.3f || b.y > 4.3f)
			.Subscribe (_ => Debug.Log ("GameOver"));
 
		var rb = ball.GetComponent<Rigidbody2D> ();
		rb.velocity = new Vector2 (0, -3);
 
		ShowArcs ();
	}
 
	void ShowArcs () {
 
		foreach (var a in arcs) a.SetActive(false);
 
		var index = Random.Range(0, arcs.Length);
		var direction = Random.Range(0, 10) > 5 ? -1 : 1;
		var i = 0;
		var color = gameColors[ Random.Range(0, gameColors.Length)];
		var arcsTotal = Random.Range(minArc, maxArc);
 
		while (i < arcsTotal) {
			arcs[index].SetActive(true);
			arcs[index].GetComponent<SpriteRenderer>().color = color;
			index += direction;
 
			if (index < 0) index = arcs.Length - 1;
			if (index == arcs.Length) index = 0;
			i++;
		}
	}
 
	void OnCollision () {
 
		minArc--;
		maxArc--;
 
		if (minArc == 0) minArc = 1;
		if (maxArc == 0) maxArc = 1;
 
		ShowArcs();
	}
 
	public void OnTouchMove (Vector3 t) {
 
		if (!touchDown)
			return;
 
		var touch = Camera.main.ScreenToWorldPoint (t);
 
		var rotationNow = gameObject.transform.eulerAngles;
 
		float angle = Mathf.Atan2 (touch.y - origin.y, touch.x - origin.x) ;
		float rotation = 180 * ( angle / Mathf.PI );
 
		Vector3 newRotation = circleRotation;
		newRotation.z += (rotation - originalAngle );
 
		if (newRotation.z >  rotationNow.z + 180) newRotation.z -= 360;
		if (newRotation.z <  rotationNow.z - 180) newRotation.z += 360;
 
		rotationSpeed = (newRotation.z - rotationNow.z) * 0.2f;
 
		rotationNow.z += rotationSpeed;
		gameObject.transform.eulerAngles = rotationNow;
	}
 
	public void OnTouchDown (Vector3 t)	{
 
		var touch = Camera.main.ScreenToWorldPoint (t);
 
		rotationSpeed = 0;
		circleRotation = gameObject.transform.eulerAngles;
 
		float angle = Mathf.Atan2 (touch.y - origin.y, touch.x - origin.x);
		originalAngle = 180 * ( angle / Mathf.PI );
 
		touchDown = true;
	}
 
	public void OnTouchRelease () {
		touchDown = false;
	}
}