How to split a growing program into Model, View, and Controller — and why that split makes agentic AI prompts sharper, debugging easier, and game logic testable without a running window.
In Lecture 11, Snake lived in one file. That was intentional — it let you focus on tools and workflow.
In this lecture, Space Invaders lives in three files. That is also intentional. When a program grows, mixing responsibilities in one place makes every problem harder to find and every AI prompt harder to write.
MVC is the solution. It is not a rule imposed from outside. It is a pattern that emerges when programmers take "which code belongs where?" seriously.
Snake in one file was a reasonable starting point. Understanding why that approach has limits is what motivates MVC.
// all in GamePanel
void paintComponent(Graphics g) {
// draw aliens
// also check if they hit bottom
// also update score
// also draw score
// also play sound??
}
void keyPressed(KeyEvent e) {
// move player
// also check collision
// also update lives
}
Model-View-Controller is a design pattern — a named, reusable solution to the problem of separating responsibilities in an interactive program.
Knows the truth about the game. Never draws anything.
Knows how to show what the Model knows. Never decides anything.
Knows when to connect the two. Owns the game loop.
A brief design step before the first prompt prevents the most common MVC mistake: letting the AI decide what belongs where.
JPanelmain methodTimerKeyListenerrepaint() each tickSame workflow as Week 1 — but now you know why each step matters.
space-invaders.Space Invaders is a different project from Snake. It has a different file structure (three files instead of one) and a different architecture (MVC). Keeping them in separate repos keeps your Git history clean and makes each project easier to navigate on GitHub.
Ctrl+Shift+P → run Git: Clone.
# Verify you're inside the repo
git status
# Should say: On branch main, nothing to commit
space-invaders folder with README.md inside.With the repo open, launch Copilot Chat:
Ctrl+Alt+I (Windows/Linux) or Cmd+Opt+I (Mac)| Mode | Best for |
|---|---|
| Inline | Small local edits, completing obvious next lines |
| Chat | Full prompts, multi-line generation, debugging conversations, MVC-scoped changes |
For this project, Chat is the primary tool. The seed prompts are designed for Chat, not inline completion.
The prompts follow MVC order deliberately. Build the skeleton first, fill each layer in isolation, then verify the Model with a plain-Java tester.
I'm building Space Invaders in Java using Swing, split into three files: GameModel.java, GameView.java, and GameController.java. GameView should extend JPanel and be hosted in a JFrame. GameController should have the main method and wire the three classes together. GameModel must have no Swing imports. For now, just create the three class shells with placeholder comments describing what each class will do. The program should compile and open a blank window.
A blank but compiling skeleton proves the three-file structure works before any logic is added. If the skeleton does not compile, fix that before anything else.
GameModel has no Swing importsFill in GameModel.java. The model should track: the player's horizontal position, the alien formation (5 rows of 11), the player's bullet (one at a time), alien bullets, the score, and lives remaining (start with 3). Add logic to: move the player left and right, fire a player bullet if one isn't already in flight, advance the player's bullet each tick, move the alien formation right until the edge then down and reverse, fire alien bullets at random intervals, and detect collisions between bullets and aliens or the player. No Swing imports.
Allowing java.awt.Rectangle in the Model is a pragmatic choice — it is a geometry class, not a GUI class. The rule is "no Swing," not "no java.awt."
Fill in GameView.java. It should take a reference to the model and draw everything the player sees: the player, the alien formation, both sets of bullets, the score, and remaining lives. Show a centered game-over message when the game ends. The view should only read from the model — it must never change game state.
GameView.java for any method call that modifies state (e.g., setScore, moveBullet). If you find one, the AI violated the View contract and that line needs to move.
Fill in GameController.java. Add keyboard controls so the player can move left and right with the arrow keys and fire with the spacebar. Add a game loop using a Swing timer that updates the model each tick and redraws the view. Stop the loop when the game is over.
Create a separate file called ModelTester.java with a main method. It should create a GameModel, call its methods directly, and print PASS or FAIL for each check. Write tests for at least five behaviors: the player cannot move past the left or right edge, firing while a bullet is already in flight does nothing, a bullet that reaches the top is removed, destroying an alien increases the score, and losing all lives triggers the game-over state. No testing libraries — just plain Java.
The Model has no Swing — so it can be tested without opening a window. This prompt checks whether the AI's game logic is actually correct, not just whether it compiles.
ModelTester compiles with GameModelPASSModelTesterOne commit per prompt. If Prompt 4 breaks something, you can return to the Prompt 3 commit. That is the point of version control.
The design pattern and the prompting practice reinforce each other. A clear architecture produces a clearer conversation with the AI.
GameModel.java, add a list of shield rectangles positioned between the player and the alien formation. Reduce a shield's health when hit by a bullet from either side. Remove the shield when health reaches zero. No Swing imports.
GameView.java's paintComponent method only, draw the shields from the model's shield list. Use the shield's health value to choose a color from full green to dim red. Do not call any model mutating methods.
GameModel.java, increase the alien movement speed each time an alien is destroyed. Expose a method the Controller can call to get the current recommended timer interval. Do not touch the View.
Notice that each prompt names the file, names the method or field, and states the constraint. That is the pattern to practice.
Testing is the practice of writing code that checks other code. It is especially valuable when working with AI-generated output.
No new libraries, no setup. Just a main method, a few helper methods, and the same GameModel your game already uses.
public class ModelTester {
static int passed = 0;
static int failed = 0;
static void check(String testName, boolean condition) {
if (condition) {
System.out.println("PASS: " + testName);
passed++;
} else {
System.out.println("FAIL: " + testName);
failed++;
}
}
public static void main(String[] args) {
testInitialState();
testPlayerMovement();
testBulletFiring();
testAlienDestruction();
testGameOver();
System.out.println("\n" + passed + " passed, " + failed + " failed.");
}
}
checkis the only new concept here. Everything else is Java you already know:staticfields,if,System.out.println, method calls.
static void testInitialState() {
GameModel model = new GameModel();
check("player starts with 3 lives", model.getLives() == 3);
check("score starts at zero", model.getScore() == 0);
check("no bullet at start", model.getPlayerBullet() == null);
check("game is not over at start", !model.isGameOver());
}
static void testPlayerMovement() {
GameModel model = new GameModel();
int startX = model.getPlayerX();
model.movePlayerRight();
check("moving right increases x", model.getPlayerX() > startX);
// Drive the player as far left as possible
for (int i = 0; i < 200; i++) model.movePlayerLeft();
check("player x never goes below 0", model.getPlayerX() >= 0);
}
static void testBulletFiring() {
GameModel model = new GameModel();
model.firePlayerBullet();
check("firing creates a bullet", model.getPlayerBullet() != null);
model.firePlayerBullet(); // fire again while one is in flight
check("cannot fire a second bullet", model.getPlayerBullet() != null);
// (this is a weak check — we want exactly one bullet, not two)
}
Compile and run from the terminal exactly like any other Java file:
javac GameModel.java ModelTester.java
java ModelTester
cannot find symbol error means a name mismatch, not a logic bug — update the test to match, or ask the AI to add the missing method.
Expected output when everything passes:
PASS: player starts with 3 lives
PASS: score starts at zero
PASS: no bullet at start
PASS: moving right increases x
4 passed, 0 failed.
PASS: player starts with 3 lives
PASS: score starts at zero
FAIL: no bullet at start
PASS: moving right increases x
3 passed, 1 failed.
A single FAIL line tells you exactly which check to look at. That is the whole point.
These tests check real game logic. Each one would be tedious or impossible to verify just by watching the game.
static void testAlienDestruction() {
GameModel model = new GameModel();
int before = model.getAlienCount();
model.destroyAlien(0, 0);
check("alien count decreases on hit",
model.getAlienCount() == before - 1);
check("score increases on hit",
model.getScore() > 0);
}
static void testGameOver() {
GameModel model = new GameModel();
// Destroy all aliens
for (int row = 0; row < 5; row++)
for (int col = 0; col < 11; col++)
model.destroyAlien(row, col);
model.checkCollisions();
check("game over when all aliens gone",
model.isGameOver());
}
Is it a compile error (cannot find symbol) or a runtime FAIL (the check ran but the condition was false)?
Re-read the condition. Did you write == 3 when the model starts with 0? Fix the test and re-run before touching the Model.
Open GameModel.java and read the method the check is testing. Is the logic obviously wrong? In Copilot Chat, ask: "The check [name] is failing. Here is the method — what is wrong with the logic?"
Accept Copilot's fix, run ModelTester again. If all checks pass, commit. If a new check fails, repeat from Step 1. Record the conversation in PROMPTS.md.
FAIL line with a descriptive name is already a precise bug report. Copilot Chat can see your open files — describe which check failed and ask what the method should do.
JUnit does the same thing ModelTester does, with more automation. Knowing the concept first makes the library easier to learn later.
The AI will produce broken code. That is not a sign that something went wrong with your prompt — it is a normal part of the workflow. The question is how to respond efficiently.
Exception in thread "AWT-EventQueue-0"
java.lang.NullPointerException: Cannot invoke
"java.awt.Rectangle.intersects(java.awt.Rectangle)"
because "this.playerBullet" is null
at GameModel.checkCollisions(GameModel.java:87)
at GameController.lambda$main$0(GameController.java:34)
at javax.swing.Timer.fireActionPerformed(Timer.java:313)
NullPointerExceptionplayerBullet is nullGameModel.java:87GameModel.checkCollisions at line 87, I am getting a NullPointerException because playerBullet is null when the method is called. Add a null check before the intersection test. Do not change any other logic.
Once the baseline runs and the core tests pass, extensions are more controlled because the architecture is in place.
These are harder. The AI will give you a first attempt that needs several rounds of follow-up. That iteration is the point — practice directing Copilot through a multi-step problem.
Alternate between two alien sprite states every N ticks to simulate the classic flicker. Model tracks a boolean animFrame; View draws different shapes based on it. The iteration challenge: keeping animation and movement in sync without doubling the speed.
When all aliens are destroyed, spawn a new formation that is faster. Model needs a wave counter and a respawnAliens() method. The iteration challenge: the game-over condition and the wave-clear condition are easy to confuse.
Alien speed, fire rate, and bullet speed all increase per wave. Model exposes multipliers; Controller passes the current wave to the timer interval calculation. The iteration challenge: getting the curve to feel playable rather than instantly impossible.
Use javax.sound.sampled to play a clip on fire, hit, and death. Controller triggers sound calls — not the Model. The iteration challenge: the AI's first attempt often blocks the game loop; ask it to use a daemon thread.
PROMPTS.md is especially valuable here.
MVC is not specific to Space Invaders. Once your code is split into Model, View, and Controller, you can apply the same structure to any game — and each new project sharpens your ability to write precise, layer-aware prompts.
Model owns the maze (a 2-D tile array), Pac-Man position, ghost positions, and pellet count. View draws tiles. Controller runs the ghost AI each tick.
LLM strategy: prompt Model first with a hard-coded boolean[][] maze. Once movement and wall collision work, ask for one ghost that follows a fixed path before adding chase logic.
Model manages a list of segments, each with its own position and direction. Segments separate when hit. Mushrooms are a second tile layer the Model tracks.
LLM strategy: prompt for a single-segment centipede that descends correctly. Add splitting only after one segment works from end to end.
Model owns the board grid and the falling piece (position + rotation). Line-clear logic lives entirely in the Model. View renders the grid; no game logic touches paint calls.
LLM strategy: describe each piece as a int[][] shape. Prompt for placement and gravity before rotation. Test line-clear logic with ModelTester before connecting the View.
Model manages Mario's position and jump arc, barrel positions, and platform layout. A gravity constant applied each tick is enough to produce realistic jumping if the Model exposes a velY field.
LLM strategy: platform layout first as a list of rectangles. Prompt for jump/fall physics before adding barrels. Use ModelTester to verify landing and death detection.
Model tracks lane objects as lists of Lane records, each with speed and direction. Controller moves all lanes each tick. View draws rows. Collision and safe-zone logic stay in the Model.
LLM strategy: implement one lane completely before adding more. Each lane type (traffic, log, turtle) follows the same interface — prompt for one, then ask Copilot to apply the pattern.