Course 2: from Python to C# + Unity โ ending with the thing you actually want: a glowing, SeeMusic-style MIDI visualiser driven by your piano.
๐ฎ C# + Unity๐น Live MIDI from your keyboard๐ Plays real .mid files๐พ Progress saves automatically
0
XP earned
0/0
Missions done
0
Day streak ๐ฅ
1
Level
๐ Every mission complete. You built a real-time MIDI visualiser โ the kind of software people pay for โ and you understand every line of it. Point a camera at that screen and play.
How this works
You finished Code Quest. You went from Scratch blocks to writing Python games from nothing โ loops, functions,
collisions, files, objects. That was the hard part, and it's done. This course does not teach you to program.
You already program. It teaches you two new tools โ the C# language and the Unity engine โ and
aims them at one target: a falling-note piano visualiser like SeeMusic, with glowing keys, particle bursts, notes
raining onto an 88-key keyboard, driven by real MIDI files and by your own piano, live.
Same rules as last time. Each mission hands you a small new technique โ the vocabulary. Then a
Build It Yourself brief gives you a checklist and you assemble the pieces. Nudges
point, they don't write. Full solutions exist behind the Last resort seals (hold
3 seconds to break one) โ for checking your work, not for starting it.
One honest warning: Unity is a professional tool, and the first week feels like a cockpit. Modules 0โ2 exist to
shrink the cockpit. From Module 3 onwards, every single mission builds a piece of the final visualiser โ
nothing is a throwaway exercise.
โ Missions, checkpoints, quizzes and bosses all pay XP. The instrument at the end is real.
Your quest map
Module 0 โ Boot Up the Engine
One-time setup, and a tour of the cockpit. The goal of this module is small on purpose: a cube on screen, spinning, because your script told it to.
โ๏ธ Install Unity and meet the cockpit
โฑ 30โ45 min
Mission goal: Unity installed, a project called SynthQuest created, and you can name the four main panels without looking.
Unity is a game engine โ the professional version of what pygame was doing for you, plus a full visual
editor. You'll write C# scripts, and Unity runs them inside its scene.
Step 1 โ Unity Hub. Go to unity.com/download and install Unity Hub. Make a free
Unity account when it asks (choose the free Personal licence).
Step 2 โ the Editor. Inside the Hub: Installs โ Install Editor and pick the newest
Unity 6 LTS version (6.3 or later โ LTS means "long-term support", the stable one). It's a big download;
let it run.
Step 3 โ the project. Hub โ New project โ choose the Universal 3D template โ name it
SynthQuest โ Create. (Universal = the URP renderer. We pick it now because in Module 8 it gives us
the glow.)
Step 4 โ the tour. When the editor opens, find these four panels. Say what each one is out loud โ really:
Panel
What it is
Scratch/pygame equivalent
Scene
Your working view of the world โ fly around, place things
The stage, but editable in 3D
Game
What the player's camera actually sees when you press โถ Play
The stage during the green flag
Hierarchy
The list of every object in the scene
Your sprite list
Inspector
Every setting of the selected object โ position, colour, and your scripts' variables
The sprite's properties, supercharged
Step 5 โ first object. In the Hierarchy: right-click โ 3D Object โ Cube. Select it and, in the
Inspector, set its Position to (0, 0, 0). Press โถ Play at the top. Nothing moves yet โ
that's correct. Press โถ again to stop.
FROM YOUR PYTHON COURSEIn pygame you wrote the window, the loop, and every draw call. Unity writes all of that for you โ the price is learning where everything lives. The cockpit feels big for about three sessions, then it disappears.
Stuck? Nudges (no full code)
Nudge 1. Hub can't find an LTS version? Use the Archive tab link inside the Hub's install screen.
Nudge 2. Lost a panel? Menu Window โ Layouts โ Default resets the cockpit.
Nudge 3. To move the Scene camera: right-mouse-drag looks around, WASD flies (while holding right mouse), scroll zooms. F with an object selected = focus on it.
๐ Your first script โ make the cube spin
โฑ 20โ30 min
Mission goal: Attach a C# script to the cube, print a message to the Console, and make the cube spin on its own.
In the Project panel (bottom): right-click โ Create โ MonoBehaviour Script (older versions say
C# Script), name it Spinner. Double-click to open it in your code editor, and make it this:
Spinner.cs
using UnityEngine;
public class Spinner : MonoBehaviour
{
void Start()
{
Debug.Log("Hello from Ashi's first script!");
}
void Update()
{
// 90 degrees per second, around the y axis
transform.Rotate(0f, 90f * Time.deltaTime, 0f);
}
}
Save the file, go back to Unity, and drag the script from the Project panel onto the Cube in the
Hierarchy. (A script does nothing until it's attached to an object โ remember that; it's the #1 "why isn't it
working" of all time.) Press โถ Play.
FROM YOUR PYTHON COURSEThis file is your pygame template, split in half. Start() = everything you wrote beforewhile running:. Update() = everything inside it โ Unity calls it every frame for you. Debug.Log(...) = print(...), and it lands in the Console panel. And Time.deltaTime? That's the polished version of clock.tick(60) โ it's the time one frame took, so "speed ร deltaTime" moves at the same real-world speed on any computer.
Stuck? Nudges (no full code)
Nudge 1. Red errors in the Console stop Play mode from starting. Double-click the error โ it opens the file at the broken line. Every statement ends with ;.
Nudge 2. Cube not spinning but no errors? Is the script actually on the cube? Select the cube โ the Inspector should show a Spinner component.
Nudge 3. The file name and the class name must match exactly: Spinner.cs โ class Spinner. Unity's rule.
Nudge 4. No Console panel? Window โ Panels โ Console.
BOSS BATTLE (optional ยท 40 XP) Make the spin speed a public float speed = 90f; field, then select the cube and change the number in the Inspector while the game is playing. Add a second cube with the same script but a different speed. One script, two behaviours โ where have you seen that idea before?
Module 1 โ Python with Armour
C# is Python wearing a suit of armour: same ideas, stricter rules. Every rule exists to catch your mistakes before the program runs. Keep the Rosetta Stone (Reference) open in another tab.
๐๏ธ Variables with types โ and the Inspector trick
โฑ 25 min
Mission goal: Write C# variables of each type, print them, and expose one in the Inspector.
In Python a variable could hold anything. In C# you declare the type and it's locked in:
new vocabulary
int score = 0; // whole number (score = 0)
float speed = 4.5f; // decimal โ note the f! (speed = 4.5)
string name = "Ashi"; // text (name = "Ashi")
bool playing = true; // true/false โ lowercase! (playing = True)
score += 1; // exactly the same as Python
Debug.Log("Score is " + score); // print("Score is", score)
Why bother with types? Because if you write score = "banana", Python happily lets you and crashes
later, somewhere confusing. C# refuses to even run โ it underlines the mistake while you type. Armour.
Now the Unity twist. Make a field public and put it outside the methods, at the top of the
class:
new vocabulary
public class Spinner : MonoBehaviour
{
public float speed = 90f; // public + outside methods = visible in the Inspector
void Update()
{
transform.Rotate(0f, speed * Time.deltaTime, 0f);
}
}
FROM YOUR PYTHON COURSEA public field is a variable with a slider attached. Unity shows it in the Inspector, and whatever you set there overrides the number in the code. This is Unity's superpower: tune your game while it runs. (Careful: changes made during Play mode reset when you stop. Everyone gets burned by this once. Now you have been warned and will get burned anyway.)
Do it: upgrade your Spinner with a public speed. Then add an int, a
string and a bool to Start() and Debug.Log a sentence using
all three.
Stuck? Nudges (no full code)
Nudge 1. Decimals need the f: 4.5f. Without it C# calls it a double and refuses to put it in a float. Armour, again.
Nudge 2. Glue text with +: "Score: " + score. C# converts the number for you.
Nudge 3. Changed the code but the Inspector still shows the old number? The Inspector value wins once it's been saved. Right-click the component โ Reset re-reads the code's defaults.
๐ Steer an object โ if, keys, and deltaTime
โฑ 30 min
Mission goal: Drive a cube left and right with the arrow keys, at a speed that's frames-per-second-proof.
C# if is Python if with brackets and braces instead of a colon and indentation:
Three new words. Input.GetKey(...) is your pygame keys[pygame.K_LEFT].
transform.position is the object's (x, y, z) โ a Vector3, which is just three floats in
a trench coat. Vector3.left is shorthand for new Vector3(-1, 0, 0).
FROM YOUR PYTHON COURSEIn pygame you wrote cat.x -= 6 and it moved 6 pixels per frame โ faster computer, faster cat. Multiplying by Time.deltaTime makes it per second instead. Every movement line you write in Unity, ever, ends in * Time.deltaTime. Tattoo it somewhere.
Do it: new script Mover on a fresh cube (remove/disable Spinner). Arrow keys steer it left
and right; give it a public speed and find a value that feels good. Then stop it escaping the camera's view โ
you know this move from the cat (Mathf.Clamp is your friend, nudge 3).
Stuck? Nudges (no full code)
Nudge 1. Nothing moves: is the script attached? Are you in Play mode? Is the Console clean?
Nudge 2. The Game view camera looks down the z axis, so left/right on screen is the x axis. If your cube walks "into" the screen you moved z.
Nudge 3. Clamping: read transform.position into a Vector3 p, then p.x = Mathf.Clamp(p.x, -7f, 7f); then write it back with transform.position = p;. (You can't change .x directly on transform.position โ C# armour strikes again; ask Dad why sometime.)
Module 2 โ Chase the Balloon 3.0
You built this game in Scratch. You built it in Python. Now build it a third time in Unity โ same game, so 100% of your brain is free for the new engine. After this module, Unity is just a tool you use.
๐ญ Technique: prefabs โ the object factory
โฑ 20 min
Mission goal: Turn an object into a prefab and spawn copies of it from code.
A prefab is a saved blueprint of an object โ its shape, colour, scripts, everything. Make one by
dragging an object from the Hierarchy down into the Project panel. It turns blue. Now you can stamp
out copies, by dragging it into the scene or from code:
new vocabulary
public GameObject balloonPrefab; // an empty slot, shown in the Inspector
void Start()
{
// stamp a copy at (2, 4, 0), with no rotation
Instantiate(balloonPrefab, new Vector3(2f, 4f, 0f), Quaternion.identity);
}
That public GameObject field shows up in the Inspector as an empty slot. Drag your prefab
from the Project panel into the slot. This drag-to-connect move is half of Unity development.
FROM YOUR PYTHON COURSEPrefab + Instantiate = your Module 6 swarm. The prefab is the class Balloon: blueprint; Instantiate(...) is calling Balloon(). Unity just lets you design the blueprint visually. (Quaternion.identity means "no rotation" โ quaternions are how 3D engines store rotation; treat the word as magic for now.)
Do it: make a sphere, colour it (right-click in Project โ Create โ Material, pick a colour, drag
the material onto the sphere), prefab it, delete the scene copy, and write a Spawner script on an
empty object (right-click Hierarchy โ Create Empty) that Instantiates 5 balloons at random
x positions in Start(). Random in C#: Random.Range(-7f, 7f).
Stuck? Nudges (no full code)
Nudge 1. A for loop in C#: for (int i = 0; i < 5; i++) { ... } โ it's Python's for i in range(5): with the machinery visible.
Nudge 2. "UnassignedReferenceException" in the Console = you forgot to drag the prefab into the slot. This error will visit you many times. Greet it politely.
Nudge 3.Random.Range(-7f, 7f) with floats includes both ends; with ints, Random.Range(0, 5) gives 0โ4. C# quirk worth knowing early.
๐ฅ Technique: triggers โ "touching?" in Unity
โฑ 25 min
Mission goal: Detect two objects touching and prove it with a Console message.
In pygame you asked every frame: cat.colliderect(balloon). Unity flips it around โ you don't ask,
Unity tells you, by calling a specially-named method on your script the moment contact happens:
new vocabulary
void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
Debug.Log("Caught by the player!");
}
}
Three switches have to be on for this to fire โ memorise this checklist, it's the eternal trigger recipe:
1๏ธโฃ Both objects have a Collider component (cubes and spheres come with one).
2๏ธโฃ The trigger object's collider has Is Trigger ticked in the Inspector.
3๏ธโฃ At least one of the two has a Rigidbody component (Add Component โ Rigidbody) โ tick Is Kinematic
so gravity doesn't yank it away.
Tags: select your player object, and at the top of the Inspector set its Tag to Player
(it's a built-in tag). That's what CompareTag checks.
FROM YOUR PYTHON COURSEThis is event-driven code: instead of you polling for collisions in the loop, the engine calls you. You've actually used this shape before โ Scratch's when I receive hat block was the same idea. Unity has a whole family of these: OnTriggerEnter, OnTriggerExit, OnCollisionEnterโฆ
Do it: put a trigger + Debug.Log on your balloon prefab, tag your Mover cube as Player, drive into a
balloon, and watch the Console. Don't move on until the message prints โ this recipe must be solid before the
build.
Stuck? Nudges (no full code)
Nudge 1. Nothing fires? Go down the checklist in order: two colliders? Is Trigger ticked on the balloon? Rigidbody on one of them?
Nudge 2. Editing the prefab: double-click the blue prefab in the Project panel to open Prefab Mode; changes there apply to every copy.
Nudge 3. Objects falling through the floor of the world? Untick gravity / tick Is Kinematic on the Rigidbody. We're doing our own movement.
๐ BUILD: Chase the Balloon, Unity edition
โฑ 60โ90 min
Mission goal: The full game: balloons fall, you catch them, the score climbs on screen.
One last piece of vocabulary โ text on screen. Unity UI lives on a Canvas: right-click Hierarchy โ
UI โ Text (TextMeshPro) (click Import TMP Essentials if a window pops up โ one-time thing). Position
the text in a corner. Then from a script:
new vocabulary
using TMPro; // at the top, next to using UnityEngine
public TMP_Text scoreText; // Inspector slot โ drag the text object in
scoreText.text = "Score: " + score; // update it whenever the score changes
BUILD IT YOURSELF
You have every piece: a steerable cube (M1-2), prefabs and spawning (M2-1), falling + respawn logic (you wrote it twice already โ transform.position += Vector3.down * ..., and when y < -6, jump back to a random x up top), triggers (M2-2), and UI text. Assemble Chase the Balloon. Tick each checkpoint as it works:
Player cube steers left/right and can't leave the view.
A balloon prefab falls, and respawns at a random x at the top when it drops off the bottom.
A spawner creates 4 balloons at Start โ all falling at different speeds (public field + Random.Range).
Driving into a balloon respawns it at the top (trigger recipe).
A catch adds 1 to a score, and "Score: n" updates on screen.
Playtest: tune speeds in the Inspector until it's actually fun, then write the good values back into the code.
Click a checkpoint to tick it โ
THINK LIKE AN ENGINEERNew question for this course, and it's yours to answer before coding checkpoint 5: which script owns the score? The balloon knows it was caught; the text lives on the Canvas; somebody must own the number. The design rule to measure your answer against: every piece of data gets exactly ONE owner, and everyone else asks the owner. Deciding who owns what is the job now โ in Python everything lived in one file; here, design is part of the build. (Stuck? The nudges have a shape for it.)
Stuck? Nudges (no full code)
Nudge 1. Build in checkpoint order and press Play after every single step. Never write two features between tests.
Nudge 2. The balloon's own script can handle falling, respawning AND OnTriggerEnter โ one blueprint that does everything, like your Balloon class in Python Module 6.
Nudge 3. How does a balloon reach the ScoreKeeper? Simplest way: public static ScoreKeeper instance; set in Awake() with instance = this; โ then anyone can call ScoreKeeper.instance.AddPoint();. (One shared instanceโฆ "static" is the keyword to quiz Dad about.)
Nudge 4. Score jumps by 3 per catch? Same bug as Python: the trigger fires several frames in a row. Respawn the balloon inside the trigger method, first line.
๐ Last resort โ full working solution
Sealed on purpose. Three scripts. Only break it if you're truly stuck โ comparing after you finish is the good use of this.
PlayerMove.cs
using UnityEngine;
public class PlayerMove : MonoBehaviour
{
public float speed = 8f;
void Update()
{
if (Input.GetKey(KeyCode.LeftArrow))
transform.position += Vector3.left * speed * Time.deltaTime;
if (Input.GetKey(KeyCode.RightArrow))
transform.position += Vector3.right * speed * Time.deltaTime;
Vector3 p = transform.position;
p.x = Mathf.Clamp(p.x, -7f, 7f);
transform.position = p;
}
}
Balloon.cs
using UnityEngine;
public class Balloon : MonoBehaviour
{
public float minSpeed = 2f;
public float maxSpeed = 6f;
float fallSpeed;
void Start()
{
fallSpeed = Random.Range(minSpeed, maxSpeed);
}
void Update()
{
transform.position += Vector3.down * fallSpeed * Time.deltaTime;
if (transform.position.y < -6f) Respawn();
}
void Respawn()
{
transform.position = new Vector3(Random.Range(-7f, 7f), 6f, 0f);
fallSpeed = Random.Range(minSpeed, maxSpeed);
}
void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
Respawn(); // first, so it can't double-fire
ScoreKeeper.instance.AddPoint();
}
}
}
ScoreKeeper.cs
using UnityEngine;
using TMPro;
public class ScoreKeeper : MonoBehaviour
{
public static ScoreKeeper instance; // the one shared ScoreKeeper
public TMP_Text scoreText; // drag the TMP text in
int score = 0;
void Awake()
{
instance = this;
}
public void AddPoint()
{
score += 1;
scoreText.text = "Score: " + score;
}
}
// Scene setup: player cube tagged "Player" with a kinematic Rigidbody;
// balloon prefab: collider with Is Trigger ticked + Balloon.cs;
// empty "Game" object with ScoreKeeper.cs and a spawner that
// Instantiates 4 balloons in Start(); Canvas with a TMP text dragged
// into the scoreText slot.
BOSS BATTLE (optional ยท 40 XP) Add a pop sound on every catch (import any .wav by dragging it into the Project panel; AudioSource component + audioSource.Play()) and a 60-second countdown shown on screen that ends the game (Time.timeScale = 0f; freezes everything โ spooky and useful).
Module 3 โ Notes Are Just Data
The visualiser starts here, and it starts with a big idea: a whole song is nothing but a list of little objects โ pitch, start time, length. Get that, and everything after is drawing.
๐ฆ BUILD: design NoteData, the class everything rides on
โฑ 40 min
Mission goal: Design and write โ yourself โ the data class the entire visualiser will be built on.
First, meet MIDI's one big idea: every piano key has a number. The lowest key on your piano (A0) is
21, middle C is 60, and the top (C8) is 108. Hold that thought โ you'll need it in a minute.
Now the C# vocabulary: a plain data class โ no using, no MonoBehaviour, never attached to
an object. Here's one that has nothing to do with music, on purpose:
new vocabulary โ a plain C# class
public class Wizard
{
public string name;
public int level;
public Wizard(string name, int level) // the constructor โ runs on "new"
{
this.name = name;
this.level = level;
}
}
And typed lists โ Python's [] in armour:
new vocabulary โ List
using System.Collections.Generic; // unlocks List โ top of the file
List<Wizard> party = new List<Wizard>(); // party = []
party.Add(new Wizard("Gandalf", 20)); // party.append(Wizard(...))
foreach (Wizard w in party) // for w in party:
{
Debug.Log(w.name + " is level " + w.level);
}
FROM YOUR PYTHON COURSEThis is Module 6 vocabulary in armour. The constructor public Wizard(...) is __init__; this.name is self.name; new Wizard(...) is Wizard(...). The only truly new thing is List<Wizard> โ a list that only accepts Wizards, so the wrong thing can never sneak in.
BUILD IT YOURSELF
Now the real work โ and it starts on paper, because this is a design problem, not a typing problem. A whole song is going to be a List of little note objects. Your job: figure out what one note must know, then write the class.
Paper first: play one single note on your piano and write down everything the visualiser would need to recreate that moment. Boil it down to the fewest facts. For each fact: a name and a C# type. (When you think you're done, ask: could these facts describe any note of any song ever written? If not, what's missing?)
Write NoteData.cs: a plain data class shaped like Wizard โ your fields, a constructor that fills them.
A SongTest script (this one IS a MonoBehaviour, on an empty object) builds a List of 5 notes in Start() and prints each one.
Feel the armour: try to Add a plain number to your list. Read the error out loud. Delete the line. That error is the whole argument for typed lists.
Click a checkpoint to tick it โ
Stuck? Nudges (no full code)
Nudge 1. Checkpoint 1: when you pressed that key โ which key was it? When did it start? How long did you hold it? Is there anything a falling block would need that isn't in that list? (Volume/how-hard is a fair answer โ you can add it in Module 7. Start minimal.)
Nudge 2. Types: key numbers are whole numbers; times in seconds are not. That's the whole decision.
Nudge 3. "The type or namespace 'List' could not be found" โ you forgot using System.Collections.Generic;.
Nudge 4. A class with no MonoBehaviour can't go on an object and has no Start/Update โ and doesn't need them. Other scripts just new it up.
๐ Last resort โ full working solution
Sealed extra-firmly in spirit: every later module builds on this class, and the design thinking โ not the typing โ is the lesson. If you break this seal, at least do checkpoint 1 on paper first and compare designs.
NoteData.cs
// A plain C# class โ pure data, no MonoBehaviour.
public class NoteData
{
public int pitch; // which key: 21 (A0) ... 60 (middle C) ... 108 (C8)
public float start; // when it begins, in seconds
public float length; // how long it lasts, in seconds
public NoteData(int pitch, float start, float length)
{
this.pitch = pitch;
this.start = start;
this.length = length;
}
}
// Yours doesn't have to match this exactly โ same facts, your names.
// (If you kept different names, keep them! Just use them consistently
// from here to the end of the course.)
๐งฑ BUILD: turn a melody into a wall of blocks
โฑ 45โ60 min
Mission goal: A hardcoded melody appears in the scene as blocks โ pitch decides where, timing decides how high up, length decides how tall. This is a freeze-frame of your final visualiser.
This mission is two puzzles, and you're unusually qualified for both.
Puzzle 1 โ transcription. Turn Hot Cross Buns into a List of your NoteData objects.
You need exactly one anchor: middle C = 60, and each piano key up or down is +1 or โ1. You know the tune's
three notes; work out their numbers from the anchor. Then decide timing yourself โ pick a beat length (something
like half a second is comfortable) and write each note's start and length. This is literally you doing sheet-music
โ data, and no programmer without your piano training gets to skip the hard part like you do. Format, so you're
not guessing syntax (one note only โ the other sixteen are yours):
format example โ first note only
song.Add(new NoteData(64, 0.0f, 0.4f)); // one note: pitch, start, length
Puzzle 2 โ the mapping. Each block needs a position from its data. You must invent two conversions:
pitch โ x (so different keys stand in different columns, in keyboard order) and start โ y (so later
notes sit higher). How is entirely your call โ sketch both conversions on paper before you code them.
BUILD IT YOURSELF
Script SongWall on an empty object, with a note prefab (a thin cube with a bright material). In Start(): build the list, then foreach note, Instantiate a block at the mapped position and stretch it with transform.localScale. Checkpoints:
The tune is transcribed: all 17 notes (count them by singing it) as NoteData lines, timing chosen by you.
Note prefab made (thin cube, bright material), connected to an Inspector slot, and all 17 blocks spawn โ count them in the Hierarchy.
Pitch maps to x: the three columns stand side by side, lowest note leftmost โ keyboard order.
Start time maps to y: reading bottom-to-top matches the tune's order in time, and simultaneous rests show as gaps.
Length maps to block height: your long "buns" notes are visibly taller than the short "one-a-penny" notes (localScale, nudge 4).
Click a checkpoint to tick it โ
THE BIG IDEALook at your Game view and hum the tune while reading it bottom-to-top. That picture is a MIDI file. Every falling-note video you've ever watched is this exact block wall, sliding downward past a camera. You've already built the hard half of a visualiser and nothing has even moved yet.
Stuck? Nudges (no full code)
Nudge 1. Transcription: count semitones on your real keyboard โ every key counts, black ones too. Check yourself: the tune's top note should come out 4 above 60.
Nudge 2. The mapping: "pitch โ x" wants the difference from some middle pitch (so the tune sits near x = 0) times a spacing number. "start โ y" is even simpler โ seconds times a height number. If your blocks are cramped or scattered, change the numbers, not the idea.
Nudge 3. Blocks off screen? Move the camera back (its z position) or shrink your multipliers.
Nudge 4. Scale: localScale = new Vector3(width, ?, depth) โ the ? should grow with n.length, using the same kind of "seconds ร height" conversion as y. A stretched cube's position is its centre, so a tall block sits half-above, half-below its y. For now that's fine โ precision comes in Module 5.
๐ Last resort โ full working solution
Sealed on purpose. Try the nudges first โ this one is closer than you think.
SongWall.cs
using UnityEngine;
using System.Collections.Generic;
public class SongWall : MonoBehaviour
{
public GameObject notePrefab;
void Start()
{
List<NoteData> song = new List<NoteData>();
song.Add(new NoteData(64, 0.0f, 0.4f));
song.Add(new NoteData(62, 0.5f, 0.4f));
song.Add(new NoteData(60, 1.0f, 0.9f));
song.Add(new NoteData(64, 2.0f, 0.4f));
song.Add(new NoteData(62, 2.5f, 0.4f));
song.Add(new NoteData(60, 3.0f, 0.9f));
song.Add(new NoteData(60, 4.0f, 0.2f));
song.Add(new NoteData(60, 4.25f, 0.2f));
song.Add(new NoteData(60, 4.5f, 0.2f));
song.Add(new NoteData(60, 4.75f, 0.2f));
song.Add(new NoteData(62, 5.0f, 0.2f));
song.Add(new NoteData(62, 5.25f, 0.2f));
song.Add(new NoteData(62, 5.5f, 0.2f));
song.Add(new NoteData(62, 5.75f, 0.2f));
song.Add(new NoteData(64, 6.0f, 0.4f));
song.Add(new NoteData(62, 6.5f, 0.4f));
song.Add(new NoteData(60, 7.0f, 1.2f));
foreach (NoteData n in song)
{
float x = (n.pitch - 60) * 0.3f;
float y = n.start * 2f;
GameObject block = Instantiate(notePrefab,
new Vector3(x, y, 0f), Quaternion.identity, transform);
block.transform.localScale = new Vector3(0.28f, n.length * 2f, 0.3f);
}
}
}
Module 4 โ Build the Piano
88 keys, generated entirely by code, that can light up on command. This is the trickiest pure-logic puzzle in the course โ the black-key pattern โ and it's all yours.
๐น BUILD: the 88-key keyboard
โฑ 60โ90 min
Mission goal: A full piano keyboard across the bottom of the screen, built by a loop, where any key can light up by pitch number.
This build gets a toolbox, not a recipe. Five new C# tools below โ some are exactly what you need,
and at least one is a red herring. Which tool fits which part of the problem (and whether you need it at all)
is yours to decide. That decision is the mission.
๐งฐ tool โ a lookup array (fixed table of answers)
// Mon Tue Wed Thu Fri Sat Sun
static readonly bool[] IS_OPEN = { true, true, false, true, true, true, false };
bool wednesday = IS_OPEN[2]; // look an answer up by index
๐งฐ tool โ % , the remainder operator
int r = 27 % 10; // 10 goes in twice (20), remainder 7 -> r is 7
int hour = 31 % 24; // 31 hours after midnight -> 7 o'clock: % "wraps around"
๐งฐ tool โ Dictionary (instant lookup by number)
using System.Collections.Generic;
Dictionary<int, string> squad = new Dictionary<int, string>();
squad[23] = "Ashi"; // store under any number
Debug.Log(squad[23]); // find it again instantly, from anywhere
๐งฐ tool โ Mathf.Abs (distance between numbers)
float gap = Mathf.Abs(3f - 8f); // 5 โ how far apart, ignoring direction
Last tool โ changing a colour from code:
new vocabulary
Renderer rend = GetComponent<Renderer>(); // the component that draws this object
rend.material.color = Color.cyan; // recolour it, live
BUILD IT YOURSELF
The spec: 88 keys (pitches 21โ108) across the bottom of the screen, looking like a real piano, built by a loop, each key findable by its pitch number and able to light up. Before any code, three design questions, on paper โ they are the real mission:
Q1. Given any pitch number, your code must answer: is this key black? Writing 88 answers by hand is banned. What's the smallest piece of data that can answer it for all 88 keys โ and what do you know about pianos that makes that possible? You're the pianist here; stare at your instrument.
Q2. Where does key n sit left-to-right? Draw one octave from above and write x positions under each key until a rule appears.
Q3. Next module, falling notes will need to find "key #60" instantly and light it. How will you store your keys so any other script can do that? And who should own a key's glow โ the builder, or the key itself? (Your answers decide how many scripts this build has.)
Paper: written answers to Q1โQ3, plus a test of your Q1 scheme: does it give the right answer for pitches 21, 60 and 61? (Check them on your piano: A0, C4, C#4.)
Two prefabs: white key (tall thin cube) and black key (shorter, darker, slightly forward in z and up in y).
PianoBuilder spawns 88 keys, parented under itself (the 4th argument of Instantiate is a parent transform โ keeps the Hierarchy sane).
The pattern is RIGHT: compare with a photo of a real piano โ groups of 2 and 3 black keys, starting from the bottom A.
Every key is named by its pitch (key.name = "Key " + pitch;) and findable by pitch number from any other script โ your Q3 design, working.
Any key can be told to light up (a public method on it) and fades back to normal in about half a second.
Proof: a 2-line test script that presses a random key every frame โ the piano twinkles.
Click a checkpoint to tick it โ
FROM YOUR PIANOYou already know everything Q1 and Q2 need better than any programmer โ you've stared at the answer for years. The code just writes down what your hands know. Verification for whatever design you invent: a real 88-key piano has exactly 52 white and 36 black keys. If your loop produces those counts, you got it right.
Stuck? Nudges (no full code)
Nudge 1 (Q1). Play every C on your piano, bottom to top. Then every C#. What's true about the key layout between any C and the next C? How many keys make up that repeating chunk?
Nudge 2 (Q1). "Where am I inside a repeating chunk?" is exactly the question one of the toolbox tools answers. Which tool wraps around?
Nudge 3 (Q1, deep). A 12-slot lookup array holds one chunk's black/white pattern; the wrap-around tool turns any pitch into a slot index. Reading the 12 slots off your own keyboard is the pianist's privilege.
Nudge 4 (Q2). The geometry, on paper: draw one octave of your piano from above. Which keys actually own a slice of the floor, all the way to the front edge? Which keys just float in the cracks? Now: as the loop walks up the pitches, which kind should make the "next x position" move along โ and which shouldn't?
Nudge 5 (Q2). So you need to count something as you loop โ not the pitch itself. Trace pitches 21, 22, 23, 24 on paper with your counter and write each one's x by hand. The rule will pop out.
Nudge 6 (Q2, deep). Keep a whiteCount. White key x = whiteCount * width, then whiteCount++. A black key sits halfway between the white just placed and the next one โ which is an x of "count minus a half, times width". Black keys never touch the counter.
Nudge 7. Centre the piano afterwards by moving the parent builder object โ that's why we parented everything.
Nudge 8 (Q3). Your Q3 answer probably needs two things: the spawned key's own script (GetComponent<YourKeyScript>() on the object you just Instantiated) and a container that finds things by number instantly โ you met one in the toolbox. Make the container static so other scripts can reach it; ask Dad why that works.
Nudge 9. Fade pattern: a float glow; Press() sets it to 1; Update does glow = Mathf.MoveTowards(glow, 0f, Time.deltaTime * 3f); then rend.material.color = Color.Lerp(normalColor, litColor, glow);. Lerp = "blend between two colours by a 0โ1 amount" โ worth playing with.
Nudge 10. Grab the normal colour in Start() (rend.material.color) so white keys fade back to white and black keys to black โ one script serves both prefabs.
๐ Last resort โ full working solution
This is the one build where the seal is most tempting. The black-key math is genuinely hard โ but it's also the exact kind of puzzle programmers get paid for. Paper first. Seal last.
PianoBuilder.cs
using UnityEngine;
using System.Collections.Generic;
public class PianoBuilder : MonoBehaviour
{
public GameObject whiteKeyPrefab;
public GameObject blackKeyPrefab;
public const float WHITE_WIDTH = 0.24f;
public static Dictionary<int, KeyLight> keys = new Dictionary<int, KeyLight>();
static readonly bool[] IS_BLACK =
{ false, true, false, true, false, false, true, false, true, false, true, false };
void Start()
{
int whiteCount = 0;
for (int pitch = 21; pitch <= 108; pitch++)
{
GameObject key;
if (IS_BLACK[pitch % 12])
{
float x = (whiteCount - 0.5f) * WHITE_WIDTH;
key = Instantiate(blackKeyPrefab,
transform.position + new Vector3(x, 0.12f, -0.18f),
Quaternion.identity, transform);
}
else
{
float x = whiteCount * WHITE_WIDTH;
key = Instantiate(whiteKeyPrefab,
transform.position + new Vector3(x, 0f, 0f),
Quaternion.identity, transform);
whiteCount++;
}
key.name = "Key " + pitch;
keys[pitch] = key.GetComponent<KeyLight>();
}
}
// Module 5 will thank us: where is any pitch, left/right?
public static float XForPitch(int pitch)
{
int whites = 0;
for (int p = 21; p < pitch; p++)
if (!IS_BLACK[p % 12]) whites++;
if (IS_BLACK[pitch % 12]) return (whites - 0.5f) * WHITE_WIDTH;
return whites * WHITE_WIDTH;
}
}
KeyLight.cs
using UnityEngine;
public class KeyLight : MonoBehaviour
{
public Color litColor = new Color(0.2f, 0.9f, 1f);
Renderer rend;
Color normalColor;
float glow = 0f;
void Start()
{
rend = GetComponent<Renderer>();
normalColor = rend.material.color;
}
public void Press()
{
glow = 1f;
}
void Update()
{
glow = Mathf.MoveTowards(glow, 0f, Time.deltaTime * 3f);
rend.material.color = Color.Lerp(normalColor, litColor, glow);
}
}
// Twinkle test (on any object):
// void Update() {
// PianoBuilder.keys[Random.Range(21, 109)].Press();
// }
BOSS BATTLE (optional ยท 40 XP) Make one octave playable from the computer keyboard: A S D F G H J = C D E F G A B (and W E T Y U for the sharps), each key calling Press() โ a tiny playable piano before any MIDI exists. Bonus: also make each press sound (one AudioSource + audioSource.pitch = Mathf.Pow(2f, (pitch - 60) / 12f); โ ask Dad why that formula works, it's a good story).
Module 5 โ Make It Rain
The note wall meets the piano. Notes fall, land on the right keys at the right moment, and the keys light up. When this mission is done, you have a working visualiser.
๐ง๏ธ BUILD: the note rain
โฑ 60โ90 min
Mission goal: Hot Cross Buns rains down onto your piano; every note lands on its key exactly on time, and the key lights while the note is "sounding".
One new piece of vocabulary โ a clock you own:
new vocabulary โ the song clock
float songTime = -3f; // field: where we are in the song (negative = lead-in)
void Update()
{
songTime += Time.deltaTime; // ticks forward in real seconds
}
Now the design decision the whole mission hangs on โ and it's yours. The spec: notes must land
exactly on time, stay in sync for an entire song, and (dream feature) survive pause and even rewind.
You've moved things one way your whole programming life: Pong-style โ give it a velocity, add it on every frame,
and position is wherever it ended up. Two design questions, on paper:
Q1. Suppose Pong-style movement is off by a tiny amount each frame โ a rounding crumb, a stuttered
frame. What does minute three of the song look like? And what would "rewind" even mean?
Q2. What do you know on every single frame โ the clock, the note's own data โ that could pin down where
a note should be, so that being wrong is impossible? (Nudge 1 checks your answer.)
Once Q2 is settled, derive the placement itself: given songTime and a note's data, where is the
note's bottom edge? One anchor is non-negotiable: at the exact moment songTime == n.start,
the bottom must touch the keyboard (y = 0). Do it on paper, and test your candidate expression three ways:
plug in "5 seconds before it plays", "the moment it plays", "1 second after". High, zero, below โ if your
expression says that, you've got it.
One geometric wrinkle you met in Module 3: a cube's position is its centre. If a note is
h tall, where must its centre sit for its bottom to sit at your computed y? (You half-solved
this at the note wall; now it has to be exact.)
BUILD IT YOURSELF
Script NoteRain: build the Hot Cross Buns list (steal your own transcription from SongWall), spawn all the blocks in Start() (keep both the Transform and its NoteData โ two Lists, same order, or a tiny helper class if you're feeling fancy), then in Update() tick the clock and re-place every note with your formula. Checkpoints:
Paper: your bottom-y expression, passing all three plug-in tests (before / at / after the start moment).
Piano from Module 4 across the bottom; camera framed so you can see the piano and about 8 seconds of sky.
Notes spawn in columns that line up over their keys exactly โ which means pitchโx must be answered by ONE piece of code shared with PianoBuilder, not invented twice (if your builder can't answer "where is pitch n?" from outside yet, refactor: Module 3 of Code Quest, but in C#. A public static method is the C# shape for it).
The rain falls: notes move smoothly down as songTime climbs, with the lead-in gap first.
Bottom-edge landing is exact: a note's bottom touches the key top at its start moment โ the centre-vs-bottom wrinkle handled.
While a note is sounding (songTime between start and start + length), its key is pressed โ light follows music. Hum along to verify.
Notes vanish (or slide out of view) after they finish, instead of piling up below the piano.
Click a checkpoint to tick it โ
FROM YOUR PYTHON COURSE(Read this after checkpoint 1 โ it names what you just discovered.) Pong moved things by velocity because nothing had to stay in sync with anything. The design you've just chosen for the rain has a family name in music software, and the test of whether you chose well is brutal and simple: does pause/rewind (the boss battle) cost you three lines, or a rewrite?
Stuck? Nudges (no full code)
Nudge 1 (Q2 check). Velocity accumulates โ every frame inherits the last frame's errors, and rewinding means perfectly un-adding everything. But the clock plus the note's start time are enough to compute a fresh, correct position from scratch, every frame. If that's where you landed: yes. Build that.
Nudge 2 (the expression, deep). Start from "how many seconds until this note plays?" โ that's a subtraction, and its sign already behaves like up/down. Then convert seconds to height: you'll want a constant meaning "one second of music = this many units", exactly like the note wall's y mapping.
Nudge 3. Two lists, same index: List<Transform> blocks and List<NoteData> datas. Loop with for (int i = 0; i < blocks.Count; i++) so you can read both.
Nudge 4. A note's on-screen height is its length-in-seconds put through the same secondsโunits conversion as the fall โ one constant rules both, or long notes won't match the music.
Nudge 5. The lit test is two comparisons and && (C#'s and).
Nudge 6. Hide finished notes with blocks[i].gameObject.SetActive(false); once songTime > n.start + n.length.
Nudge 7. Everything works but lands slightly early/late everywhere? Your keys' tops aren't at y = 0. Either move the piano parent down by half a key height, or add one offset constant. Measure, don't guess.
๐ Last resort โ full working solution
Sealed. The golden formula plus the nudges really do cover it. Compare afterwards.
NoteRain.cs
using UnityEngine;
using System.Collections.Generic;
public class NoteRain : MonoBehaviour
{
public GameObject notePrefab;
public float scrollSpeed = 2f; // world units per second of music
public float keyTopY = 0f; // y where note bottoms should land
List<NoteData> datas = new List<NoteData>();
List<Transform> blocks = new List<Transform>();
float songTime = -3f; // lead-in before the first note
void Start()
{
datas.Add(new NoteData(64, 0.0f, 0.4f));
datas.Add(new NoteData(62, 0.5f, 0.4f));
datas.Add(new NoteData(60, 1.0f, 0.9f));
datas.Add(new NoteData(64, 2.0f, 0.4f));
datas.Add(new NoteData(62, 2.5f, 0.4f));
datas.Add(new NoteData(60, 3.0f, 0.9f));
datas.Add(new NoteData(60, 4.0f, 0.2f));
datas.Add(new NoteData(60, 4.25f, 0.2f));
datas.Add(new NoteData(60, 4.5f, 0.2f));
datas.Add(new NoteData(60, 4.75f, 0.2f));
datas.Add(new NoteData(62, 5.0f, 0.2f));
datas.Add(new NoteData(62, 5.25f, 0.2f));
datas.Add(new NoteData(62, 5.5f, 0.2f));
datas.Add(new NoteData(62, 5.75f, 0.2f));
datas.Add(new NoteData(64, 6.0f, 0.4f));
datas.Add(new NoteData(62, 6.5f, 0.4f));
datas.Add(new NoteData(60, 7.0f, 1.2f));
foreach (NoteData n in datas)
{
GameObject g = Instantiate(notePrefab, transform);
float h = n.length * scrollSpeed;
g.transform.localScale = new Vector3(0.2f, h, 0.2f);
blocks.Add(g.transform);
}
}
void Update()
{
songTime += Time.deltaTime;
for (int i = 0; i < datas.Count; i++)
{
NoteData n = datas[i];
float h = n.length * scrollSpeed;
float bottomY = keyTopY + (n.start - songTime) * scrollSpeed;
blocks[i].position = new Vector3(
PianoBuilder.XForPitch(n.pitch), bottomY + h / 2f, 0f);
bool sounding = songTime >= n.start && songTime <= n.start + n.length;
if (sounding) PianoBuilder.keys[n.pitch].Press();
if (songTime > n.start + n.length)
blocks[i].gameObject.SetActive(false);
}
}
}
// NoteRain's object should sit at the same x as the PianoBuilder object
// (or make both x = 0) so columns line up over keys.
BOSS BATTLE (optional ยท 40 XP) Add playback controls: space pauses (a bool that stops the clock ticking), โ โ scrub time backwards/forwards (just add to songTime โ notes will obediently fly up or down, because position comes from time), and R restarts. You'll need to re-activate hidden notes when time rewinds.
Module 6 โ Real Songs, Real MIDI
Hardcoded Hot Cross Buns retires. You'll install a real library, read actual .mid files, and pour any song on the internet into the visualiser you already built.
๐ BUILD: load any .mid file
โฑ 60 min
Mission goal: Your visualiser plays a real MIDI file โ a song you chose โ with hundreds of notes you never typed.
What's in a .mid file? Exactly your NoteData idea: which key, when, how long โ plus tempo
info, tracks and channels. It's a 40-year-old format and every piano video you've watched runs on it. You
could decode the raw bytes yourself (the format is public), but professionals reach for a tested library
first. Ours is DryWetMIDI.
Install: the docs live at melanchall.github.io/drywetmidi โ find "Using in Unity". Short
version: grab the DryWetMIDI.Nativeless release zip from the GitHub releases page and unzip it into
Assets/Melanchall/DryWetMIDI. When Unity finishes importing with a clean Console, you're in. Reading
install docs and following them is the skill being trained here โ it's how you'll add every tool for the
rest of your life.
The new vocabulary โ a foothold into someone else's classes, which is the point. Here's the way in,
plus one worked example โ converting a note's start moment into real seconds:
new vocabulary โ the way in
using Melanchall.DryWetMidi.Core;
using Melanchall.DryWetMidi.Interaction;
MidiFile midi = MidiFile.Read(path); // the whole file, parsed
TempoMap tempoMap = midi.GetTempoMap(); // the tick<->seconds converter
foreach (var note in midi.GetNotes()) // every note in the file
{
var t = note.TimeAs<MetricTimeSpan>(tempoMap); // its start, as real time
float startSec = t.TotalMicroseconds / 1000000f;
// Each "note" also knows which key it is and how long it lasts.
// Finding those two is YOUR job โ the library's docs list everything
// a Note can tell you, and the length conversion rhymes with the
// one above. Welcome to the real skill: reading docs.
}
Where do files go? Make a folder in your Project panel named exactly StreamingAssets โ
Unity copies whatever's inside it next to the built game. Get the path with
System.IO.Path.Combine(Application.streamingAssetsPath, "song.mid"). Free .mid files are everywhere
โ search "free piano midi" (bitmidi, mutopiaโฆ); start with something you can hum.
BUILD IT YOURSELF
Design question first, on paper: some code must turn a .mid file into a List of your notes, and NoteRain must play it. Where does the file-reading code live, and what does NoteRain get to know about it? Judge your own design against checkpoint 5's acceptance test before writing it: swapping to a different song must touch one string and nothing else. If your design passes that, you've independently invented something with a fancy name โ ask Dad what it's called, after you've built it.
DryWetMIDI imported, Console clean, and the using Melanchall... lines are recognised (no red).
A .mid file sits in StreamingAssets, and MidiFile.Read on it doesn't throw.
Your loader hands back a List<NoteData>; Debug.Log the count and the first three notes โ sanity-check the numbers against the tune (a piano piece should mostly live 40โ90).
NoteRain runs on the loaded list โ the real song rains and lights keys correctly.
Swap in a second .mid by only changing the file name field. If nothing else needs touching, your design is right.
Click a checkpoint to tick it โ
FROM YOUR PYTHON COURSEThis is Module 5 of Code Quest (reading the high-score file) scaled up: outside data โ your program's objects. Notice what changed since then: back then reading a number from a file was the mission. Now you're parsing a binary music format through a professional library and it's Tuesday.
Stuck? Nudges (no full code)
Nudge 1. Red squiggles under Melanchall? The library isn't imported where Unity can see it โ check the folder landed under Assets, and check the Console for import errors.
Nudge 2. "Could not find file" โ Debug.Log the full path you built and look at it. Is the folder named exactly StreamingAssets? Is the extension really .mid and not .mid.txt (Windows hides extensions โ ask Dad to unhide them)?
Nudge 3. Loader's shape: make the list, foreach over midi.GetNotes(), new NoteData(...) per note with the converted values, return the list. ~12 lines.
Nudge 4. Docs hunt: on the DryWetMIDI site, find the Note class page. You're looking for a property that says which key (its name starts like "note numโฆ") and a method that converts the length the same way TimeAs converts the start.
Nudge 5. Big files: thousands of blocks is fine for now. If it chugs, pick a smaller song and file "spawn only what's near" under Module 8 polish.
๐ Last resort โ full working solution
Sealed. The vocabulary block plus nudge 3 basically writes it.
MidiLoader.cs
using UnityEngine;
using System.Collections.Generic;
using Melanchall.DryWetMidi.Core;
using Melanchall.DryWetMidi.Interaction;
public class MidiLoader : MonoBehaviour
{
public List<NoteData> Load(string fileName)
{
string path = System.IO.Path.Combine(
Application.streamingAssetsPath, fileName);
MidiFile midi = MidiFile.Read(path);
TempoMap tempoMap = midi.GetTempoMap();
List<NoteData> song = new List<NoteData>();
foreach (var note in midi.GetNotes())
{
float start = note.TimeAs<MetricTimeSpan>(tempoMap)
.TotalMicroseconds / 1000000f;
float length = note.LengthAs<MetricTimeSpan>(tempoMap)
.TotalMicroseconds / 1000000f;
song.Add(new NoteData(note.NoteNumber, start, length));
}
Debug.Log("Loaded " + song.Count + " notes from " + fileName);
return song;
}
}
NoteRain.cs โ the changed part
public string fileName = "song.mid";
public MidiLoader loader; // drag the MidiLoader in
void Start()
{
datas = loader.Load(fileName); // the 17 Adds are gone
foreach (NoteData n in datas)
{
GameObject g = Instantiate(notePrefab, transform);
float h = n.length * scrollSpeed;
g.transform.localScale = new Vector3(0.2f, h, 0.2f);
blocks.Add(g.transform);
}
}
BOSS BATTLE (optional ยท 40 XP) Piano MIDI files usually store left and right hand as separate channels (grab note.Channel into NoteData). Colour the falling notes by hand โ the two-colour waterfall is the signature SeeMusic look. You'll need the note block's Renderer and two public Colors.
Module 7 โ Going Live
The moment this course exists for: a USB cable from your piano to the computer, and the screen answers your hands.
๐ BUILD: your piano drives the screen
โฑ 60โ90 min
Mission goal: Every key you physically press lights its on-screen twin, instantly.
Hardware first. Plug the piano into the computer over USB (if it only has round MIDI sockets you need a
cheap USB-MIDI adapter). No driver drama on a modern keyboard โ the OS just sees a MIDI device. Close any other
program that might grab it (only one app can own a MIDI device at a time โ future debugging gold).
Install Minis โ a small library that feeds MIDI into Unity's Input System. The install teaches a new
trick: a scoped registry. In Unity: Edit โ Project Settings โ Package Manager, add a registry โ
Name Keijiro, URL https://registry.npmjs.com, Scope jp.keijiro โ then in
Window โ Package Manager โ My Registries, install Minis. It pulls in the Input System package; when
Unity asks to enable the new input backend and restart, say yes โ then go to Project Settings โ Player โ
Active Input Handling and set it to Both, or your Input.GetKey code from earlier modules
dies. (That trap costs real developers an afternoon. It just cost you one sentence.)
The vocabulary. Minis is event-driven โ remember triggers? The device calls you:
new vocabulary
using UnityEngine.InputSystem; // plus: using Minis; if you name its types
InputSystem.onDeviceChange += (device, change) =>
{
if (change != InputDeviceChange.Added) return;
var midi = device as Minis.MidiDevice;
if (midi == null) return;
midi.onWillNoteOn += (note, velocity) =>
{
// note.noteNumber = the same 21..108 you've used all course
// velocity = how hard, 0..1
};
// Fingers also LIFT. This event has a twin โ you'll want it
// for checkpoint 5. Guess its name, or skim Minis' README.
};
That (a, b) => { ... } shape is a lambda โ a tiny function with no name, defined right
where it's handed over. You've been writing named functions since Code Quest Module 3; this is the same thing,
minus the name.
And one new tool for the box โ whether and where you need it is your call:
๐งฐ tool โ Queue (a first-in, first-out list)
using System.Collections.Generic;
Queue<string> letters = new Queue<string>();
letters.Enqueue("first"); // put at the back
letters.Enqueue("second");
string next = letters.Dequeue(); // take from the front -> "first"
BUILD IT YOURSELF
Script LiveMidi. One engine FACT before you design โ from the docs, non-negotiable: MIDI callbacks arrive between frames, and scene objects only tolerate being touched from Update(). That fact hands you this build's design problem: the callback knows what happened but must not act; Update() may act but doesn't know. Design the hand-off between them โ on paper first. Checkpoint 2 is your design, working.
Piano connected; a Debug.Log in the note-on callback prints pitch + velocity when you press a real key. (Celebrate this one properly โ your piano just talked to your code.)
Your hand-off design working: callbacks only record what happened; Update() is the only place that touches keys or scene objects.
Real keys light their on-screen twins via PianoBuilder.keys. Play a scale, watch it chase you.
Guard rail: a pitch outside the Dictionary (some keyboards send weird stuff) is ignored, not a crash โ ContainsKey.
Hold-test: keys stay lit while held, release on note-off (KeyLight needs a Hold()/Release() upgrade โ design it yourself).
Click a checkpoint to tick it โ
FROM YOUR PIANOYour piano has been broadcasting these exact messages โ note number, velocity, on, off โ through that socket since the day it was built. Nothing was ever "recorded into the app": MIDI is a live wire, and now you're the one holding it.
Stuck? Nudges (no full code)
Nudge 1. Callback never fires? Check the piano shows up: Window โ Analysis โ Input Debugger lists connected devices. Not there โ cable/adapter/another app owns it. There but silent โ your registration code has a bug.
Nudge 2. Plugged in before pressing Play? Fine โ Minis fires an Added event for already-present devices when the game starts. If you're paranoid, log every device change.
Nudge 3 (design). One side of your program knows things the other side must act on, later. Where have you seen that split in real life? A doorbell demands action NOW; a mailbox holds facts until you're ready. Which one fits the engine fact?
Nudge 4 (design, deep). Callbacks write what-happened into a waiting line; Update() empties the line and acts. The toolbox Queue was built for exactly this โ first thing recorded, first thing handled.
Nudge 5. For checkpoint 5: note-on records +pitch, note-off records -pitch โ one line, sign is the message. (Or make a tiny struct. Both are legitimate; the second is prettier โ Dad will have opinions.)
๐ Last resort โ full working solution
Sealed. Checkpoint 1 is copy-adjacent from the vocabulary; the rest is the queue and the Dictionary you already own.
LiveMidi.cs
using UnityEngine;
using UnityEngine.InputSystem;
using System.Collections.Generic;
public class LiveMidi : MonoBehaviour
{
// +pitch = pressed, -pitch = released (the sign is the message)
Queue<int> events = new Queue<int>();
void OnEnable()
{
InputSystem.onDeviceChange += OnDeviceChange;
}
void OnDisable()
{
InputSystem.onDeviceChange -= OnDeviceChange;
}
void OnDeviceChange(InputDevice device, InputDeviceChange change)
{
if (change != InputDeviceChange.Added) return;
var midi = device as Minis.MidiDevice;
if (midi == null) return;
Debug.Log("MIDI device connected: " + midi.description.product);
midi.onWillNoteOn += (note, velocity) =>
{
events.Enqueue(note.noteNumber); // record only โ no scene work here
};
midi.onWillNoteOff += (note) =>
{
events.Enqueue(-note.noteNumber);
};
}
void Update()
{
while (events.Count > 0)
{
int e = events.Dequeue();
int pitch = Mathf.Abs(e);
if (!PianoBuilder.keys.ContainsKey(pitch)) continue;
if (e > 0) PianoBuilder.keys[pitch].Hold();
else PianoBuilder.keys[pitch].Release();
}
}
}
KeyLight.cs โ the upgrade
bool held = false;
public void Hold() { held = true; glow = 1f; }
public void Release() { held = false; }
public void Press() { glow = 1f; } // old one-shot, still used by NoteRain
void Update()
{
if (!held)
glow = Mathf.MoveTowards(glow, 0f, Time.deltaTime * 3f);
rend.material.color = Color.Lerp(normalColor, litColor, glow);
}
BOSS BATTLE (optional ยท 40 XP) Use velocity: pass it through the queue (this forces the tiny-struct upgrade from nudge 3) and make hard presses glow brighter โ Color.Lerp(litColor, Color.white, velocity) is a decent start. Then spawn a small rising block from each pressed key while it's held โ live mode's answer to the falling rain.
Module 8 โ The Glow-Up
It works. Now make it gorgeous. Bloom, emissive materials and particles are what separate "my school project" from the videos you binge. This module is mostly editor craft โ fewer lines, more taste.
โจ BUILD: bloom, glow and sparks
โฑ 60โ90 min
Mission goal: Notes and lit keys genuinely glow, and landing notes throw sparks.
How glow works โ two halves that must both be on:
1๏ธโฃ Bloom (the camera side): a post-processing effect that makes very bright pixels bleed light.
Hierarchy โ right-click โ Volume โ Global Volume โ in its Inspector, Add Override โ Post-processing โ
Bloom; set Intensity โ 1, Threshold โ 1. On your Main Camera, tick Post Processing. (This is
why we chose the Universal template in Module 0 โ long game.)
2๏ธโฃ Emission (the object side): a material can emit light instead of just reflecting it. Tick
Emission on the material, and push the emission colour's Intensity above 1 โ that's what makes
pixels bright enough for bloom to grab. From code:
Particles: Hierarchy โ Effects โ Particle System. It's a toy box โ play with Start Speed, Start
Size, Emission rate, Shape, and Color over Lifetime until you have a spark burst you like. Untick Looping,
tick Play On Awake off, prefab it, and fire it from code:
new vocabulary
public ParticleSystem sparkPrefab;
ParticleSystem s = Instantiate(sparkPrefab, position, Quaternion.identity);
s.Play();
Destroy(s.gameObject, 2f); // clean up after itself, 2s later
BUILD IT YOURSELF
No new logic today โ you're wiring beauty into hooks you already built (KeyLight's press moments, NoteRain's landing test). Checkpoints:
Bloom on: a test cube with an emissive material (intensity > 1) visibly glows in the Game view.
Falling notes use an emissive material โ the waterfall glows against a near-black background.
KeyLight drives emission, not just colour: pressed keys flare with bloom and fade clean.
Sparks fire exactly when a note's bottom meets the keyboard (you already compute that moment every frame โ it's your landing formula crossing the key line).
Art pass: pick a 2โ3 colour palette on purpose (waterfall colour โ key-light colour), darken the background, frame the camera. Screenshot before/after.
Click a checkpoint to tick it โ
THINK LIKE AN ENGINEERNotice what made this module cheap: NoteRain already knew the exact landing moment, KeyLight already owned "how I look". Good structure earlier = beauty is a plug-in later. When a change feels weirdly easy, that's not luck โ that's architecture paying rent.
Stuck? Nudges (no full code)
Nudge 1. No glow anywhere? Camera's Post Processing tickbox is the classic miss. Second classic: emission colour intensity still at 0.
Nudge 2. Landing detection: you want the single frame where the note's bottom crosses the key line โ track a bool per note ("has landed") so it fires once, not sixty times. You solved this exact bug with the balloon score in Module 2.
Nudge 3. Everything glowing = nothing glowing. Keep the background and white keys non-emissive; spend glow only where the music is.
BOSS BATTLE (optional ยท 40 XP) Big-file performance: load a heavy .mid (10k+ notes) and only SetActive(true) blocks within ~10 seconds of songTime. Watch the Stats button (Game view) before/after. Congratulations, you just did real optimisation โ measured first, fixed second.
๐ Capstone โ Your Visualiser, Your Name
Everything merges into one finished instrument with your name on it. No new vocabulary. No nudges. No seal. You're the engineer now.
๐ Ship it
โฑ a few sessions โ worth it
Mission goal: One app, two modes, your aesthetic โ captured on video with real music.
THE BRIEF
Combine everything into a single polished build:
Two modes with a clean switch (a key or an on-screen button): Song mode (a .mid rains down) and Live mode (your playing lights and sparks in real time).
Play-along: song mode with live input at the same time โ your real presses flare over the falling guide notes. (You have both systems; the work is making them share the piano politely.)
Your signature look โ palette, background, camera angle, particles. Steal moods from your favourite visualiser videos, then make one choice they didn't.
One feature nobody assigned you. Ideas if none arrive: a progress bar, note trails, camera sway with pitch, a title screen with the song name, sustain-pedal glow (MIDI CC 64 โ Minis exposes itโฆ docs time).
Build it: File โ Build Profiles โ Build โ a real .exe that runs outside the editor. Remember StreamingAssets ships with it; that was the plan all along.
Record a video of you playing your piano with your visualiser on screen. Show the family. Send it to me.
Click a checkpoint to tick it โ
THE POINTTwo courses ago you were snapping blocks together. Now you've built a real-time graphics application that speaks a hardware protocol, parses a binary file format through a third-party library, and looks good doing it. The secret you should take with you: it never stopped being "variables, loops, ifs and functions". It never will.
๐ Reference โ The Rosetta Stone
Python on the left, C#/Unity on the right. When your fingers type Python, look here. Or hit Quiz me and drill.
Python / pygame
C# / Unity
What's going on
๐ด Flashcards โ test your translation
Press Quiz me to get a random Python line โ then pick its C# twin.
MIDI never says "play for 0.5s" โ it says onโฆ then off. Length is derived.
Velocity
0..1 in Minis (0โ127 raw)
How hard the key was struck
Channels
0โ15
Piano files often split hands across channels/tracks
Sustain pedal
CC 64
"Control change" messages โ knobs and pedals live here
๐ง When Unity breaks (it will โ that's normal)
Red in the Console = Play won't start. Double-click the error to jump to the line. The two greatest
hits: a missing ;, and mismatched { }. The message names the file and line โ read it
like a traceback.
NullReferenceException is C#'s NameError-with-attitude, and 90% of the time in Unity it means an
empty Inspector slot โ you declared public GameObject thing; and never dragged anything in. The
other 10%: GetComponent returned nothing because the component isn't on that object.
Script does nothing? The sacred checklist: Is it attached to an object? Did you save the file? Is the
Console clean? Are you actually in Play mode?
Trigger doesn't fire? Two colliders, Is Trigger ticked, a Rigidbody on one side. Always one of the
three.
Changes vanished? You edited during Play mode. Play-mode changes revert on stop. Everyone pays this tax
once; the pros pay it annually anyway.
"The name X does not exist" โ spelling, capitalisation (C# cares: Update โ
update), or a missing using line at the top.
Golden rule, unchanged since pygame: change one thing, press Play, look. Small steps beat big rewrites.
๐งโ๐ป Note for Dad (the coach)
Course 2 notes โ what's different now that it's Unity.
0
Total XP
0/0
Quiz Qs correct
0
First-try correct
0
Solution seals broken
0
Days active
The arc: Modules 0โ2 shrink Unity's cockpit by rebuilding a game he's built twice (deliberate: zero
novel game-logic load while the editor is novel). From Module 3, every mission is a component of the final
visualiser โ the note-data model (3), the keyboard (4), time-driven rendering (5), file parsing via library (6),
live hardware input (7), rendering polish (8). Nothing is a throwaway.
Editor friction replaces syntax friction. In Python the enemy was indentation; here it's unassigned
Inspector slots, missing components and Play-mode edits vanishing. The troubleshooting page covers the top
offenders โ point at it rather than fixing it for him, same drill as before.
Two setup cliffs, not one. Module 0 (install, ~an hour, do it together) and Module 7 (MIDI hardware +
the Active Input Handling โ Both trap, which is called out in the mission text but worth you knowing:
installing Input System silently disables the old Input.GetKey API unless set to Both). Check the
piano appears in Unity's Input Debugger before he starts that session. Also decide his code editor in Module 0 โ
VS Code with the Unity extension, or Visual Studio Community; either is fine, IntelliSense is non-negotiable.
The builds are bigger now. Chase 3.0 and the piano are 60โ90 minute builds with mid-build checkpoints โ
each checkpoint is a stopping point that still pays XP. Low-focus day: one checkpoint is a win, stop there. The
piano's black-key math (Module 4) is the course's one real algorithm puzzle โ if there's a session you sit in on,
that's the one. Bring paper.
New engineer-brain thread: this course starts asking design questions Python never forced โ which
script owns the score, why callbacks shouldn't touch the scene, why XForPitch wants to be static,
what "separation of concerns" buys. Several missions plant "ask Dad" hooks (statics, the 2^(n/12) pitch formula,
struct vs sign-hack). Those are your openings โ take the tangent, that's where the engineering gets passed down.
Library choices made for you: DryWetMIDI (Nativeless build, dropped into Assets) for .mid parsing;
Minis via Keijiro's scoped registry for live input. Both are the community-standard picks and both installs are
themselves teaching moments (release zips; scoped registries).
Progress lives in this browser's localStorage โ same as Code Quest, different key, so both courses
coexist. Export/Import in the sidebar for backup or another machine.
๐ค Tutor โ setup & oversight
The ๐ค Tutor button gives Ashi a guarded AI helper (DeepInfra, OpenAI-compatible). Its rules: Socratic
nudges and concept explanations, hard no-code (a client-side filter blocks and retries any code-shaped reply),
on-course topics only, and it refers to you after ~3 stuck turns or anything off-course โ referrals are
counted and flagged below. A daily message cap protects both the struggle-first learning loop and your bill.
Grounding: with a Context7 key set, the tutor must look up real documentation (Unity, C#,
DryWetMIDI, Minis) before answering API questions, and answers carry a "๐ answered from the official docs" tag.
It also has a local search over the course's own text โ with sealed solutions, deep nudges and quiz answers
excluded, so it can explain without spoiling. Without a docs key it's instructed to say "I'm not sure" rather
than guess.
Honest caveat: the key and rules live in this browser โ a motivated student who's literally being
taught to read code could dig them out. Your defences: the full transcript below, the referral/blocked counters,
your DeepInfra usage dashboard, and a spend limit set on the DeepInfra side. The key is stored only in this
browser's localStorage โ never in this file, and never included in progress exports.
0
Messages today
0
Messages total
0
Referrals to you
0
Code replies blocked
0
Docs lookups ๐
๐ค Synth Quest Tutor
๐งโ๐ป This one's a Dad question โ go grab him! (I've flagged it in the coach's corner.)