askl cat (BCJ 2020)

Making a Cute Rhythm Game From Start to Finish

24 min. read

Theme: Prevail

So the theme for this game jam is prevail. When it was announced, the first thing that came to mind was battling against a lot of enemies. I thought about classic RPGs and melee combat games - but for some reason, rhythm gaming stuck with me. I think it’s mainly due to the genre’s crazy skill curve, players’ self-improvement over time, and how we feel like we are on top of the world when we play amazingly.

Click here if you’d like to try out the game! (.7z download)

Dusky the Cat

This character was made the night before the game jam. However, most of the time spent on rigging was allocated for the start of the event. It took about 12 hours to complete the character before starting on animations. Here’s the workflow of my creation from start to finish.

Proxy and 2D Reference

Cat Proxy

Before modeling anything important, I try to obtain 2D reference either by making them or grabbing them from my team. The 2D references do not have to be fully flushed out because our goal is to get the front and side view of the character and props. This reference will be used as a guide that bridges 2D and 3D for shape and volume.

In Maya, I start laying out the references into image planes. I then insert spheres that get shaped to match the 2D references from both the side view and front view. These are known as the proxy mesh. This allows me to get a nice and simple shape for the character that I can then later quad-draw over.

Topology

Cat Topology

Now that you have your reference mesh, it’s time to start modeling. The first step in modeling most characters would be to live-surface the proxy and use the quad-draw tool to draw out the topology of the mouth and eyes. We start out with the mouth and eyes because the overall topology of the head will be influenced by the face.

When the character’s face is done, I start to wrap my way from the forehead to the back of the head and under. I delete the faces where I think the ears and nose will be and then start extruding inwards to have a simple edge loop around those parts.

The head is done at this point. I start making my way down towards the body and feet. I apply the same extruding edge-loop process for the arms and foot. I incorporate topology for the shoulders instead of having stub-hands so that way there’s enough information for the character to rotate their arms upwards and backwards without it looking weird.

The body should be faster to model compared to the head since there is less information to work with. However, the topology on the arms and feet could get tricky if you want to set them up for animation.

Silhouette

Cat Silhouette

The silhouette is an integral component of the character’s design, at least in my opinion. Memorable characters should be distinguishable from just their silhouettes alone in any given pose.

You can view the character’s silhouette by pressing the 7 key in Maya as long as if there’s no light source. You can also use the soft select tool to adjust the shape of the character mesh so that it looks nice from any given angle. I like to go to the orthographic views so that I can make any possible design tweaks on the fly.

Mouth Setup

Cat Mouth Setup

I like to include a simple mouth rig whenever possible. There’s just something cool about moving the jaw joint and seeing the character come to life!

What I’ve done in the setup above is that I’ve created a temporary rig for the mouth. This makes it so that I can visualize how the mouth, teeth, and tongue will look like after its been skin-binded. It’s a good idea to put the jaw joint around the centre of the head so that when you rotate the jaw, the skin around the mouth moves backwards and not just downwards. I made this mistake when creating the temporary rig.

Controls and Joint Setup

Cat Rigging

I highly recomend taking a look at my other blog post if you are interested in rigging. It only took about 30 minutes to set up all the controls and constraints because of the workflow that I’ve set up for myself.

What I didn’t show in this post so far is the skin weight painting process for the character. It’s pretty straight-forward except for the annoying stuff that happens 90% of the time. I like to have the Normalized Weights option set to Interactive on the skin-weight painting tool, and have the color gradient ramp on so I can see where things screw up.

Its critical to ensure that your character mesh is perfectly symmetrical; otherwise, you will have a bad time mirroring the skin weights. And speaking of that, when you finally get the point to mirroring your skin weights, make sure that the skin weights are actually synmetrical, otherwise you will give yourself a headache. I like to have my mirror skin weights options set to:

  • Surface Association = Closest component
  • Influence Association 1 = Label
  • Influence Association 2 = One to one
  • Normalize = false

Face Rigging

Cat Face Test

Although it wasn’t needed, I included a simple face rig for my character. Setting up the mouth rig is the coolest part of every project. Like mentioned earlier, this is when your character comes to life.

I’ve also painted some of the eyes’ influence onto the face so that when we move the eyes, the skin of the character moves very subtly - which gives it a more natural feeling when the character looks around.

Reverse-Foot Setup and Stretchy IK

Cat Foot Test

The cat also has a reverse-foot setup so that the character can go on their tippy-toes. The stretchy IK also allows me to control where the foot is planted when I am animating. Setting up the stretchy IK system requires the use of the node editor in Maya - but it is fairly simple to set up, and I plan on showing how to do that in a future tutorial.

Cloth

Cat Cloth Test

I was working on the character at home and tried testing out the cloth for the clothing, but it just kept on breaking. Apparently the UV’s had to be unwrapped properly for god knows why. The cloth system in Unity also does not support two materials being applied to the cloth at once, so I had to create my own texture instead of using basic Blinn materials for the scarf.

In hindsight, It was a good idea to test out the cloth before implementation. It has allowed me to mitigate any possible risk of running into blockers during the game jam.

Animations

Cat Animation Test

This is my favorite part in the character development pipeline. You get to see your baby come to life!

I did not touch animations until the gameplay was just about complete. This was an issue for the art department since there were only 8 hours left in the event to get things together. A lot of the animations’ quality had to be sacrificed due to the lack of time for polish.

I went with the agile approach for my animation workflow. The process is basically just finishing a really rough version of all the animations at the same time and then iterating them together at the same time. This process has allowed me to get the work done while swiftly building up quality consistently over time.

Bat

Bat Modelling

The bat went through the same process as the cat - except that this time, everything was done in the game jam. It took about 7 hours to complete the bat along with animations. Most of that time was spent modelling the wings.

Bat Animations

This is the bat’s flying animation for the game. I made a death animation when the cat hits it, but the death animation sucks.

Environment

Environment Assets

It took about 2-3 hours to complete the environment. A lot of this speed was accomplished by adhering to the 20/80 rule, meaning that I have to focus on 20% of the work to achieve 80% of the results.

I used a lot of simple hacks such as duplicating branch pieces, using the lattice tool to perform a high-level deform, and the soft select tool to make minor adjustments. All that’s needed to make the environment feel vibrant are 3 sets of trees for each species.

Something that I wish I had done was create a 2d design for the mushrooms. I did not like how they had turned out.

Environment Modelling

Adhering to the 20/80 rule.

Tree Workflow

Something cool about the pine trees are that they follow the golden ratio. The branches were rotated 137.5 degrees to match the angle of the golden ratio in nature.

Object Pooling System

I want to stress out that you should always plan out your low-level systems before working on gameplay. You will learn the hard way when you have to refactor!

The object pooling system exists because whenever you instantiate and destroy a game object, you are putting strain on the CPU. When you do that a lot, your frames will start to dip. How it works is that it reuses previously instantiated assets by putting them into a “pool.” That pool is filled with inactive objects - but when I need an instance of the object, I take it out of the pool and make it active again. I’ve applied the object pooling system to HitObjects, Background Objects, and the Timing Lines (the white lines that appear every song measure.)

As you will notice, I’ve documented my code during the game jam event (which isn’t necessary). It’s a good habit that everybody should be getting into. And yes, I am aware of the “CreateObkect” mistake!

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
public class PoolManager
{
/// <summary>
/// </summary>
public Dictionary<APoolable, List<APoolable>> ActivePoolObjects { get; private set; } = new Dictionary<APoolable, List<APoolable>>();

/// <summary>
/// </summary>
public Dictionary<APoolable, Queue<APoolable>> InactivePoolObjects { get; private set; } = new Dictionary<APoolable, Queue<APoolable>>();

/// <summary>
/// Reference to the last object that got spawned
/// </summary>
public APoolable LastActiveObject { get; private set; }

/// <summary>
/// This method will spawn an object from the pool
/// </summary>
/// <param name="prefab"></param>
public APoolable SpawnObject(APoolable prefab, bool doNotSpawn = false)
{
// Creates a new dict ref it it doesnt exist yet
if (!InactivePoolObjects.ContainsKey(prefab))
CreateDict(prefab);

// Creates a new object if one does not exist in the pool
if (InactivePoolObjects[prefab].Count == 0)
CreateObkect(prefab);

LastActiveObject = InactivePoolObjects[prefab].Dequeue();
ActivePoolObjects[prefab].Add(LastActiveObject);

if (!doNotSpawn)
LastActiveObject.Spawn();

return LastActiveObject;
}

/// <summary>
/// This method will despawn an object and remove it from the active pool
/// </summary>
/// <param name="prefab"></param>
/// <param name="clone"></param>
public void DespawnObject(APoolable prefab, APoolable clone)
{
// Check to see if the dictionary has the given key
if (!ActivePoolObjects.ContainsKey(prefab))
Debug.LogError($"{prefab} pooled object does not exist");

// Finds the index of the removed object
var index = ActivePoolObjects[prefab].IndexOf(clone);
if (index == null || index == -1)
Debug.LogError($"{clone} does not exist in the object pool!");

// Queues the removed object back into the pool
clone.Despawn();
ActivePoolObjects[prefab].RemoveAt(index);
InactivePoolObjects[prefab].Enqueue(clone);
}

/// <summary>
/// Clears the object pool
/// </summary>
public void ClearObjectPool()
{
foreach (var i in ActivePoolObjects)
foreach (var obj in i.Value)
obj.Despawn();
ActivePoolObjects = new Dictionary<APoolable, List<APoolable>>();
InactivePoolObjects = new Dictionary<APoolable, Queue<APoolable>>();
}

/// <summary>
/// Instantiates a new poolable object
/// </summary>
/// <param name="prefab"></param>
private void CreateObkect(APoolable prefab) => InactivePoolObjects[prefab].Enqueue(GameObject.Instantiate(prefab));

/// <summary>
/// This method will create an object so you can pool it later
/// </summary>
/// <param name="prefab"></param>
private void CreateDict(APoolable prefab)
{
InactivePoolObjects[prefab] = new Queue<APoolable>();
ActivePoolObjects[prefab] = new List<APoolable>();
}
}

Game Manager

The game manager should normally only be managing game states and very high-level stuff. However, since this is a game jam, I don’t really care. I’ve simply just put all of the game’s logic into this class. I’ve posted some of my game manager code below.

Key Press / Hit Detection

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
public class GameManager : MonoBehaviour
{
// ...

/// <summary>
/// </summary>
private void HandleKeyPress()
{
foreach (var keybind in _keybinds)
{
// Check to see if key is pressed
if (!Input.GetKeyDown(keybind.Key))
continue;

// Check to see if there's an active hit object in the pressed lane
HandleAnimation(keybind.Value);
if (_activeHitObjects[keybind.Value].Count == 0)
continue;

var hitob = _activeHitObjects[keybind.Value].Peek();
var delta = hitob.Data.StartTime - (_timeElapsed - HitOffset) * 1000f;
if (delta > _timingWindows[Judgement.Miss])
continue;

HandleHit(keybind.Value);
}
}

/// <summary>
/// </summary>
private void HandleHit(int lane)
{
var hitob = _activeHitObjects[lane].Dequeue();
var delta = hitob.Data.StartTime - (_timeElapsed - HitOffset) * 1000f;
var judgement = Judgement.Miss;

_barTargetDelta = -delta * 3f;
foreach (var judge in _timingWindows)
{
if (Mathf.Abs(delta) <= judge.Value)
{
judgement = judge.Key;
break;
}
}

// Compute judgement and score
ScoreManager.TrackJudgement(judgement);
hitob.Kill(judgement);
}
}

Elapsed Time

When you are making a rhtyhm game, reading the audio clip’s time directly will sometimes yield inaccurate results. This due to how the bass.net library in unity computes it. I like to interpolate between delta time and audio time so that the audio/play time translation is more smooth.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// <summary>
/// This will calculate how much time has elapsed during game
/// </summary>
private void HandleElapsedTime()
{
if (_timeElapsed < 0)
{
_timeElapsed += Time.deltaTime;
return;
}

if (_timeElapsed >= 0 && !_audioSource.isPlaying)
_audioSource.Play();

// Interpolate between delta time and song time
_timeElapsed = (Time.deltaTime + _timeElapsed + _audioSource.time) / 2f;

// Check to see if we should end the game
if (_timeElapsed >= _audioSource.clip.length - 0.3f)
EndGame();
}

Hit objects

Hit objects are pretty straight forward. They are objects that get controlled by the game manager, and have a HitObjectData struct attached to it.

They rely on the game manager because (a) they have to be synced to the music somehow, and (b) it’s more easier to handle score when they sure being referenced and manipulated in the game manager.

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
public class HitObject : APoolable
{
public HitObjectData Data { get; private set; }

// ... (Awake() and other declarations)

/// <summary>
/// </summary>
/// <param name="data"></param>
public void Initialize(HitObjectData data, PoolManager poolManager)
{
Data = data;
// ... (Initialization)
if (data.Lane == 2 || data.Lane == 3)
{
_indicatorA.SetActive(true);
_indicatorB.SetActive(false);
_character.transform.localPosition += Vector3.up * 1.12f;
}
else
{
_indicatorA.SetActive(false);
_indicatorB.SetActive(true);
_character.transform.localPosition += Vector3.up * 0.3f;
}
}

/// <summary>
/// </summary>
/// <param name="songTime"></param>
public void UpdatePosition(float songTime)
{
if (_wasKilled)
return;
transform.position = Vector3.forward * ScrollSpeed * (Data.StartTime/1000f - songTime);
}

/// <summary>
/// </summary>
public void Kill(Judgement judgement)
{
_wasKilled = true;
_indicatorA.SetActive(false);
_indicatorB.SetActive(false);
_judgementText.text = judgement.ToString();
_judgementText.transform.parent.gameObject.transform.position = _character.transform.position + Vector3.up * 0.5f;
_judgementText.color = JudgeToColor[judgement];
_animator.SetTrigger("on-death");
_judgementAnimator.SetTrigger("on-judgement");
Invoke("ResetJudgementText", 0.3f);
if (judgement == Judgement.Miss || judgement == Judgement.Okay)
{
_batMesh.gameObject.SetActive(false);
Invoke("LateDespawn", 0.9f);
}
}

/// <summary>
/// </summary>
private void ResetJudgementText() => _judgementText.text = "";

/// <summary>
/// This is triggered via animator
/// </summary>
public void Explode()
{
_killParticle.Play();
_batMesh.gameObject.SetActive(false);
Invoke("LateDespawn", 0.9f);
}

/// <summary>
/// </summary>
private void LateDespawn()
{
if (gameObject.active)
_poolManager.DespawnObject(OriginalPrefab, this);
}
}

Score Manager

The score manager manages and stores hit data. The manager is set up in a way so that the stats could be referenced by multiple classs such as the game manager and main menu.

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
public class ScoreManager
{
/// <summary>
/// Total score the player will get after hitting or missing a note
/// </summary>
private Dictionary<Judgement, float> _accuracyValues = new Dictionary<Judgement, float>()
{
{Judgement.Perfect, 100},
{Judgement.Great, 90},
{Judgement.Good, 30},
{Judgement.Okay, - 100},
{Judgement.Miss, -25}
};

/// <summary>
/// This keeps track of the player's judgements
/// </summary>
public Dictionary<Judgement, int> JudgementCount = new Dictionary<Judgement, int>(){
{Judgement.Perfect, 0},
{Judgement.Great, 0},
{Judgement.Good, 0},
{Judgement.Okay, 0},
{Judgement.Miss, 0}
};

/// <summary>
/// The player's current combo
/// </summary>
private int _currentCombo;

/// <summary>
/// The player's max combo
/// </summary>
private int _maxCombo;

/// <summary>
/// This will get the player's current accuracy
/// </summary>
public float GetAccuracy()
{
int total = 0;
float count = 0;
foreach (var judge in JudgementCount)
{
total += judge.Value;
count += _accuracyValues[judge.Key] * judge.Value;
}

if (total <= 0 || count / total <= 0)
return 0;

return count / total;
}

/// <summary>
/// </summary>
/// <param name="judgement"></param>
public void TrackJudgement(Judgement judgement)
{
JudgementCount[judgement]++;

if (judgement == Judgement.Miss || judgement == Judgement.Okay)
{
_currentCombo = 0;
return;
}

_currentCombo++;
_maxCombo = _currentCombo > _maxCombo ? _currentCombo : _maxCombo;

}
}

Gameplay

Charting

I made a simple script in javascript that parses .osu files into c# code (in plain text). The code for the tool is very scuffed, and I’m not going to bother posting it here. And the parser I used flips the notes horizontally for some reason, but it’s not really a problem.

Charting

Charting refers to how notes are laid out in the game in accordance to the music. The charting style that I went for is very simple. I tried to keep the gameplay feeling dynamic, so I varied up the patterning multiple times in accordance to the “feeling” of the music.

I avoided using chords entirely and focused on the character’s animations when placing down the notes. I’ve used jacks to emphasize the build-ups, and added two-handed trills for the bass rolls since they are fun to play and they’re easy to read.

Rhythm Game Terminology:

  • Chord: more than one note at pressed at the same time
  • Jack: two notes that repeat in succession
  • Trills: notes that follow a “121212” sequence

Play-testing

I learned that my game is hard - even on the “noob” difficulty. It’s probably due to the fact that there’s a lot of things overlapping the notes after hitting them, and how the notes are presented on screen. I don’t really think the chart made it difficult, but that also could’ve been toned down on the easiest difficulty. After getting used to the game for 8 hours, it was easy forget that others will struggle even on the most banal difficulty.

Post-Processing and UI

Post Processing

The post-processing and UI was done near the end of the event. I like to avoid post-processing during development entirely because I believe that games should look good without it. Developing with post-processing on kinda constrains your creativity, and it’s never a good idea to add unnecessary constraints early on in the project.

The UI was not a huge deal for the project since most of the focus is on the gameplay. A bit more work could’ve been spent on it, however.

Takeaways

I noticed that most of my time was spent on developing the art for the game. Art is something that can’t be rushed, but at the same time I had to put an end to each asset at some point. It was somewhat annoying that I have to live with imperfections, but that’s the reality we have to accept as artists I guess.