This week, I made a game called CLEARCUTTER
It’s a game where you take control of two rockets simultaneously, and try to cut down as many trees as possible in 2 minutes, while not hitting them with the rockets. the closer your rockets are together the faster you accelerate, but if they touch, you explode and lose speed.
There were several tasks that I aimed to achieve for this game that I had not attempted before
- Modifying terrain at runtime with minimal frame loss
- Creating an endless obstacle course with a scaling challenge over time
- Optimization of program
- Creation of an effective high score table that persist across play sessions
- Use of visual and aural feedback systems (like juice)
- Creation of a ‘trail renderer’ tool, that could use shapes as its starting point
- Use of visuals instead of text to describe mechanics to the player
I excelled in the areas of simple coding or setting up structures that I had previously set up in other projects. This sounds like it should go without saying, but there is a startling difference in the time taken between this and something new. A problem that I had was with transitioning from the planning stage to the coding stage when I had the idea, but not the knowledge of implementing it and I had to spend a lot of time researching the subject. I also had issues with clumsily bodging a solution together. This is normally good enough for simple code or a small project but becomes an issue when other structures begin to depend on it, or when refactoring the code is required. It looks like spaghetti and pressing changes to a single line can cause huge issues if you can even understand what it does. I of course also had issues with getting stuck on a problem that I did not know the solution to. It meant hours of research over things that were often simple or silly mistakes I had made earlier.
When compared to work I had experience in, there were hours of difference. This was made obvious to me when setting up the high score table. It is just three arrays of data stored into player prefs, and then set and shifted under correct circumstances. Here is an example of that code.
void Start()
{
// check if this is first play
if (PlayerPrefs.GetInt("HasScore") < 1)
{
WriteNewScores();
}
//clear out the restart option text until it is available
restartText.text = " ";
// fade in the scene from black
GameObject.FindWithTag("ScreenFade").GetComponent<ScreenFade>().FadeIn();
LoadScores();
input.interactable = false;
newHighscore.enabled = false;
// if the gamecontroller exists, get the score and speed from it
if (GameObject.FindGameObjectWithTag("GameController") != null)
{
gameController = GameObject.FindGameObjectWithTag("GameController");
gameScore = gameController.GetComponent<UI>().GetTreeCutScore();
topSpeed = gameController.GetComponent<UI>().topSpeed * 100;
CheckHighScore(gameScore);
}
//if not, set up from title scene transition
else
{
fromTitle = true;
newHighscore.enabled = true;
newHighscore.text = "HIGHSCORES";
restartText.enabled = true;
restartText.text = "Press A to return";
}
// now load the new scores in case of highscore and display them for the player
LoadScores();
DisplayScores();
// destroy the game controller, as it is no longer needed (and to stop it from looping through the game with restart)
Destroy(gameController);
}
void Update()
{
// if the player presses A, and is able, restart the game
if (Input.GetButtonDown("Fire1") && inputDone)
{
StartCoroutine(FadeTimer());
GameObject.FindWithTag("ScreenFade").GetComponent<ScreenFade>().FadeOut();
}
}
IEnumerator FadeTimer()
{
// destroy the screen fade object so it doesnt loop with restart, and load the title scene after 1 second
yield return new WaitForSeconds(1);
Destroy(GameObject.FindWithTag("ScreenFade"));
Application.LoadLevel("StartScene");
}
void WriteNewScores()
{
// (for first play) set up the highscore table with 10 scores so it is not empty
for (int index = 0; index <= 9; index++)
{
PlayerPrefs.SetInt("Score" + index.ToString(), 120 - (index * 12 + 20));
PlayerPrefs.SetString("Name" + index.ToString(), "MAX");
PlayerPrefs.SetString("Speed" + index.ToString(), (400 - (index * 9 + Random.Range(-200, 100))).ToString("0.00"));
PlayerPrefs.SetInt("HasScore", 1);
}
}
void DisplayScores()
{
// clear the score text field
scoreText.text = "";
// and write the scores stored in the arrays to screen
for (int index = 0; index < scoreArray.Count; index++)
{
scoreText.text += (index + 1) + " " + nameArray[index] + " " + scoreArray[index].ToString() + " " + speedArray[index] + "GM/H" + "\n";
}
}
void LoadScores()
{
//clear out the arrays
scoreArray.Clear();
nameArray.Clear();
speedArray.Clear();
// and add the player pref stored scores back into them
for (int index = 0; index <= 9; index++)
{
scoreArray.Add(PlayerPrefs.GetInt("Score" + index.ToString()));
nameArray.Add(PlayerPrefs.GetString("Name" + index.ToString()));
speedArray.Add(PlayerPrefs.GetString("Speed" + index.ToString()));
}
}
public void GetInput()
{
// receive the input from the input field and convert to upper case;
nameInput = input.text.ToUpper();
// if the input is not blank, to stop clicking auto restarting, then set the players input into the name player pref, using the stored int as the index key
if (nameInput != "")
{
input.interactable = false;
PlayerPrefs.SetString("Name" + tempInt.ToString(), nameInput);
input.text = "";
}
// refresh the text display to show the new score & input, show restart info & allow restart
LoadScores();
DisplayScores();
inputDone = true;
restartText.text = "Press A to restart";
}
void CheckHighScore(int value)
{
// if we came from the title, dont do this
if (!fromTitle)
{
foreach (int item in scoreArray)
{
// go through each score in the array, and if we have one that is higher, run update score & set text to reflect this to player
if (value > item)
{
inputDone = false;
UpdateScore(value);
input.ActivateInputField();
input.interactable = true;
newHighscore.enabled = true;
newHighscore.text = "!! NEW HIGHSCORE !!";
break;
}
else
{
//if not, display the players score and info, and allow restart
newHighscore.enabled = true;
restartText.enabled = true;
newHighscore.text = "You cut " + gameScore.ToString() + " trees, with top speed of: " + topSpeed.ToString("0.00") + "GM/H" + "\n";
restartText.text = "Press A to restart";
}
}
}
}
void UpdateScore(int value)
{
// run through array bottom to top (lowest to highest scores)
for (int index = 9; index >= 0; index--)
{
// the first score that is higher than the players score will cause the score below it to be replaced
if (value > scoreArray[index])
{
PlayerPrefs.SetInt("Score" + (index + 1).ToString(), PlayerPrefs.GetInt("Score" + index.ToString()));
PlayerPrefs.SetString("Name" + (index + 1).ToString(), PlayerPrefs.GetString("Name" + index.ToString()));
PlayerPrefs.SetString("Speed" + (index + 1).ToString(), PlayerPrefs.GetString("Speed" + index.ToString()));
LoadScores();
DisplayScores();
}
else
{
// if the score is lower than the players' then it will be shifted down a slot.
PlayerPrefs.SetInt("Score" + (index + 2).ToString(), PlayerPrefs.GetInt("Score" + (index + 1).ToString()));
PlayerPrefs.SetString("Name" + (index + 2).ToString(), PlayerPrefs.GetString("Name" + (index + 1).ToString()));
PlayerPrefs.SetString("Speed" + (index + 2).ToString(), PlayerPrefs.GetString("Speed" + (index + 1).ToString()));
// set the players score to the correct slot
PlayerPrefs.SetInt("Score" + (index + 1).ToString(), gameScore);
PlayerPrefs.SetString("Speed" + (index + 1).ToString(), topSpeed.ToString("0.00"));
PlayerPrefs.SetString("Name" + (index + 1).ToString(), " ");
tempInt = index + 1;
// load and display the new score, with a gap for the players name
LoadScores();
DisplayScores();
break;
}
if (index == 0)
{
// if we hit the top slot, shift it down and set the players score there
PlayerPrefs.SetInt("Score" + (index + 1).ToString(), PlayerPrefs.GetInt("Score" + index.ToString()));
PlayerPrefs.SetString("Name" + (index + 1).ToString(), PlayerPrefs.GetString("Name" + index.ToString()));
PlayerPrefs.SetString("Speed" + (index + 1).ToString(), PlayerPrefs.GetString("Speed" + index.ToString()));
PlayerPrefs.SetInt("Score" + index.ToString(), gameScore);
PlayerPrefs.SetString("Speed" + index.ToString(), topSpeed.ToString("0.00"));
PlayerPrefs.SetString("Name" + index.ToString(), " ");
tempInt = index;
LoadScores();
DisplayScores();
break;
}
}
}
}
This is messy and not very optimal, but at under 200 lines, it should have taken an hour or so, not several.
Another issue I had was choosing a solution that I previously worked with and knew pretty well, that was not suited to the job. To check for collisions with trees, and cut them down as the player flew past them, I did a raycast check every frame from one rocket to the other. It worked fine, right up to the point where the rockets were moving. The problem was that the area of detection would eventually move so fast as to start on one side of an object you are trying to detect, and in the next frame, have moved to the other side of it, missing it completely. This issue is known as tunneling, which you get when you introduce high speeds or small objects to your game. There are several complex and elegant solutions that will avoid a tunneling issue, but what I chose to do was create a collision box whose size, rotation and position would change to reflect the amount of movement that would occur in each frame. In short, its depth was equal to the player’s speed, it was always looking at one of the rockets to keep its rotation, and its position was in between the rockets, and half their speed ahead of them.
This setup is so effective that I am confident it would detect collisions at any speed.
I tried to add feedback loops into the game, or juice and a lot of these were based on speed. To help the player feel as though they were moving fast, I made the field of view scale up to a point as they gained speed. It seems fairly subtle until you have a sudden loss in speed from collision. Once the speed reached 275, a particle emitter switched on and created shockwave like rings around the rocket. I also scaled the sizes of the rockets exhaust trail and light to reflect the acceleration from the rockets proximity to one another. In testing, this was usually noticed by the player a minute into their first run or less. This, coupled with the high score table led most players to try another run, so they could try and implement their idea of the best strategy. This best strategy was almost always the one I had wanted the player to attempt from the design stage. Along with camera effects like bloom, sun shafts, audio effects for knocking down trees and a high energy BGM, these were the positive juice items that I included.
Negative juice items were things like warning signals and explosions. The warning signals seemed reasonably effective in the early game at conveying to the player that they should not collide with trees and seemed to raise stress levels in the late game with an almost constant warning alarm going off. There was an issue with a lack of collisions at high speed, as the detection is from a single raycast forwards. I chose to leave this in, as the difficulty of reaching extreme speeds would be impossible, and would remove some of the enjoyable experience that could be had in my game. The explosions happen at the end of the run, which was received well, and also when the rockets collide with each other, which created a problem. The explosion takes up a large amount of the screen, is very bright and very loud: it is an extremely strong feedback. At the start of the game, as the rockets are constantly drawn together, they will collide. To a new player, this causes stress and the player learns that the rockets must be kept apart. Because of this, many new players held the rockets as far apart from each other for the whole run, missing the lesson of proximity = acceleration. This causes a slow and boring run, when the player realizes they were playing it wrong (from the high scores, which are now daunting multitudes greater than the players score) they will give up. This might be fixed by ramping up the strength of that attraction over the first 10 seconds, so there is no explosion at the start unless they collide the rockets themselves (but may make the controls confusing) or by better explaining this mechanic before the player is allowed to play the game (for the first run).
While creating this game, I had some serious optimization issues, which were highly problematic due to the large variation in speed, and immense top speed (~100 units/frame is the highest I have seen). The FPS would often drop below 30, when at these speeds I needed it to sit at 60. On some computers, it would even sit well above 90 for a while, which also made the game unplayable as the controls were way too touchy and the player had no time to react to anything. Moving terrains, spawning in hundreds of trees despite speed and fading out every material on a tree as you destroyed it, lots of grass and poorly optimized code were causing these issues. I received help to identify what was causing the severe loss in framerate, cleared up the code to make it as light as possible, changed the way my trees were spawned in (instead of just every half second, it also waited till the player had moved forward 5 units, so that if you were moving slowly there wasn’t a huge wall of trees spawning) and reduced the quality of the models, as since you are moving so fast, there is no time to notice model detail. Reducing the size of the terrains helped with the hang time of moving them, and once the other frame rate issues were fixed, there was plenty of time left for the operation to complete before causing a hang.
I am fairly happy with how this game turned out and if there is a good reason to keep working on it I have some plans with more things I could do to it.