Technical Case Study: Re-engineering Whisker Post-COSMOS
→ Back to: Whisker 2 dev log · Whisker v1 (COSMOS)
Jump to: The game · v1 bottlenecks · COSMOS epiphany · v2 re-engineering · Results
A note on the code here: The C# below is representative of the actual implementations. The real scripts live in the Whisker 2 GitHub repo — the architecture this page describes matches the committed code.
The Game
Whisker is a 2.5D cozy baking game built with Unity and C#. You play a cat baker taking food orders from animal customers across ~15 levels, with a branching story that leads to two different endings.
The core technical challenge was building systems that could hold across a game 7× larger than the original — dialogue, orders, tutorial, and the oven mechanic all needed to be modular enough to work without per-scene patching.
Whisker 2 has ~15 levels. The experience I was going for: cozy on the surface, with just enough mechanical satisfaction — the oven timer clicking over, the post-it order matched to the right ingredient — that the player wants one more level.
v1: Architectural Bottlenecks
Most portfolios show what worked. This section is about what didn't — and what I learned from it.
Before attending COSMOS, my development process was intuition-driven. As Whisker's scope grew beyond a two-level jam prototype, three architectural problems became clear:
Hardcoded dialogue — no runtime character identity
In Whisker v1, dialogue boxes had no character name display. The speaker's name was either missing entirely or assigned as a hardcoded string in the scene. Players at the COSMOS showcase frequently lost track of who was speaking. There was no mechanism to resolve a speaker's name at runtime — every scene was manually patched.
// DialogueTrigger.cs — v1 (Willow's scene)
// Speaker name manually typed into every scene that shows dialogue.
// Broke silently when Willow appeared in a second scene under a different name.
[SerializeField] private string speakerName = "Willow"; // hardcoded in Inspector
void Start() {
dialogueBox.speakerNameLabel.text = speakerName;
dialogueBox.ShowLine(openingLine);
}
Adding a sixth character meant hunting every scene that referenced her name. It didn't scale.
Text-based orders — no visual identity, no reuse
Orders in v1 were plain text strings. For two levels with a small fixed order set, this worked. Scaling to ~15 levels with randomized orders required something fundamentally different: each order needs visual identity, and the display system needs to work the same way regardless of which order is active. The text approach had no path to either.
Tutorial as written instructions — describing before demonstrating
The v1 tutorial was three lines of on-screen text. At the COSMOS showcase, players skipped them and got stuck on the first order. I had written it as documentation — state the rule, then let them play.
I recognized the same pattern from teaching at CACC Chinese School: if you explain how a loop works before students have written one, the explanation doesn't land. Both problems have the same fix: demonstrate first, explain after.
The COSMOS Epiphany
Attending COSMOS was a turning point for how I think about game architecture. Working on a four-week deadline with a three-person team made one thing concrete: decisions made on day one determine whether the team can keep moving on day twenty — or keeps patching the same systems over and over.
Coming home, I had a choice: keep building on top of v1's architecture, or rebuild the foundation. I rebuilt.
The thing COSMOS made concrete was the cost of deferred decisions. In the four-week project, every architecture choice made in week 3 became a constraint in week 4. The dialogue names being hardcoded wasn't a problem until we had four characters across two levels — then it was a problem every day. I built Whisker 2 with the week-4 version of myself in mind: the one who wants to add one more level and not touch five scene files to do it.
v2: The Re-engineering
To address the bottlenecks in v1, I rebuilt three systems from scratch. The guiding principle: each system should be reusable across all 15 levels with zero per-scene patching.
Dynamic dialogue name resolver
Instead of hardcoding speaker names per scene, I built a dialogue manager that resolves a character's display name at runtime from a data component on the speaker's GameObject. Every scene now uses the same dialogue prefab — adding or renaming a character requires one change, not fifteen.
// DialogueManager.cs — v2
// Speaker name comes from the character's own component, resolved at runtime.
public void PlayDialogue(DialogueSpeaker speaker, string[] lines) {
speakerNameLabel.text = speaker.displayName; // no hardcoded string
StartCoroutine(TypeLines(lines));
}
// DialogueSpeaker.cs — one component per character GameObject
public class DialogueSpeaker : MonoBehaviour {
[SerializeField] public string displayName; // set once in Inspector
}
What this solves: Willow, Finn, Mia, Charlie, Dog, Old Cat Granny — each has one DialogueSpeaker component. Rename a character: one Inspector field. No scene surgery.
Post-it note order system
Orders in v2 are prefab assets (multi-color post-it notes) instantiated at runtime, not text strings embedded in scenes. Each order type is a prefab variant; a randomized order is drawn from a pool at level start. The order display doesn't know or care which orders it's showing — it just renders whatever prefab it receives.
// OrderManager.cs — v2
// Orders are prefabs selected from a pool, not strings typed into scenes.
[SerializeField] private GameObject[] orderPrefabs; // drag prefab variants in Inspector
[SerializeField] private Transform[] orderSlots;
void Start() {
SpawnOrderSet();
}
void SpawnOrderSet() {
var shuffled = orderPrefabs.OrderBy(_ => Random.value).ToArray();
for (int i = 0; i < orderSlots.Length; i++) {
if (i >= shuffled.Length) break;
Instantiate(shuffled[i], orderSlots[i].position, Quaternion.identity, orderSlots[i]);
}
}
What this solves: Visual orders that work without reading; randomization that adds replayability; a display system that scales to any number of order types without touching its own logic.
Oven mechanic — state-based reusable pattern
The oven was in the design document from v1 but never implemented. In v2 it follows a four-state loop: ingredient placed → cook timer → product appears → delivery animation. Critically, this is the same pattern I'm using for a planned beverage machine — ingredient slot, timer, output slot. The logic is shared; only the assets differ.
// OvenManager.cs — v2
// Four states: Idle → Cooking → Ready → Delivering.
[SerializeField] private SerializableDictionary<IngredientType, GameObject> recipeTable;
[SerializeField] private float cookDuration = 2.5f;
[SerializeField] private GameObject cookProgressIndicator;
[SerializeField] private Transform outputSlot;
[SerializeField] private Animation deliveryAnimation;
private bool isProcessing;
public void ReceiveIngredient(IngredientType input) {
if (isProcessing || !recipeTable.ContainsKey(input)) return;
StartCoroutine(CookingLoop(input));
}
IEnumerator CookingLoop(IngredientType input) {
isProcessing = true;
cookProgressIndicator.SetActive(true); // state: Cooking
yield return new WaitForSeconds(cookDuration); // timer
cookProgressIndicator.SetActive(false);
Instantiate(recipeTable[input], outputSlot.position, Quaternion.identity);
deliveryAnimation.Play(); // state: Delivering
isProcessing = false; // state: Idle
}
What this solves: The oven is no longer a one-off object hardwired to a single scene. The same OvenManager drives both the oven and the planned beverage machine — different recipeTable, same state loop.
Highlight-based tutorial
The new tutorial is UI panel-driven: the game highlights the relevant UI element, the player performs the action, and only then does explanatory text appear. No player can skip the action by skipping the text — the text doesn't appear until they've done it.
This is the same teaching principle I use at CACC: demonstrate first, explain after. A student doesn't learn a loop by reading about it; they learn it by running one.
Results & Takeaways
The clearest result of the re-engineering is operational: I can add a new level without touching any existing system. In v1, adding content meant patching dialogue names, order strings, and UI per scene. In v2, each system is a prefab I drop in.
Unity Profiler comparison — 6 customers active, level 8:
v1 pattern (text strings, hardcoded names):
~44 FPS average
GC.Collect spikes every 3–4 seconds from TextMeshPro string allocations
v2 (prefab orders, runtime names):
Stable 60 FPS
GC.Collect absent during normal gameplay loops
The spike source in v1: each frame the order text label was being set via string
interpolation inside Update(), generating allocations on every frame the order
was visible. Prefab instantiation on level start, with no per-frame allocation, eliminated
the pattern entirely.
See also: Finding the frame cost in Sharpshooters — I apply the same Unity Profiler methodology to Whisker 2 when measuring performance changes.
Building Whisker 2 taught me that the most important architectural decision isn't the cleverest one — it's the one that keeps the system legible when you come back to it three weeks later. That's the engineering habit I'm bringing to USC.
→ Read the full development log, roadmap, and teaching connection: Whisker 2 dev log
→ See where the story started: Whisker v1 — COSMOS 2026