Lecture 12

MVC, Space Invaders, and Testing: Structuring Code and Prompts Like a Software Engineer

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.

MVC pattern Space Invaders ModelTester Swing Agentic AI
The Central Idea

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.

Part 1

Why One File Stops Working

Snake in one file was a reasonable starting point. Understanding why that approach has limits is what motivates MVC.

What "Tangled" Code Looks Like

One class doing too much

// 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
}
      
What this makes hard
  • You cannot test collision logic without painting
  • AI does not know which edits are safe
  • Fixing a drawing bug might break movement
  • Adding a feature means reading all of it
A tangled file is not a sign of failure. It is a sign that the project has grown past what one class was designed to do.
Part 2

What MVC Is

Model-View-Controller is a design pattern — a named, reusable solution to the problem of separating responsibilities in an interactive program.

MVC in a Diagram

flowchart LR U([User input]) --> C[Controller] C -->|calls methods on| M[Model] M -->|state changes| M C -->|triggers repaint| V[View] V -->|reads state from| M V -->|renders to| S([Screen])
Model

Knows the truth about the game. Never draws anything.

View

Knows how to show what the Model knows. Never decides anything.

Controller

Knows when to connect the two. Owns the game loop.

Part 3

Designing Space Invaders Before Writing Code

A brief design step before the first prompt prevents the most common MVC mistake: letting the AI decide what belongs where.

Space Invaders: What Each Layer Owns

Model — GameModel.java
  • Player position (x only)
  • Player bullet
  • Alien grid (5 × 11)
  • Alien bullets (list)
  • Score and lives
  • Movement logic
  • Collision detection
  • Game-over flag
View — GameView.java
  • Extends JPanel
  • Holds a reference to Model
  • Draws player, aliens, bullets
  • Draws score and lives
  • Draws game-over message
  • No game logic
  • No state mutation
Controller — GameController.java
  • Has main method
  • Creates Model, View, Frame
  • Owns the Timer
  • Owns the KeyListener
  • Calls Model methods on input
  • Calls repaint() each tick
Before You Prompt

Create the Repo and Open Copilot Chat

Same workflow as Week 1 — but now you know why each step matters.

Step 1 — Create the GitHub Repo

  1. Go to github.com and sign in.
  2. Create a new repository named space-invaders.
  3. Check Add a README.
  4. Click Create repository.
  5. Copy the repository URL.
Create the repo first, then clone it. Writing code in a random folder and trying to "add Git later" creates avoidable confusion.
Why a fresh repo for Week 2?

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.

Step 2 — Clone and Open in VS Code

  1. Open VS Code.
  2. Press Ctrl+Shift+P → run Git: Clone.
  3. Paste the repository URL.
  4. Choose a local folder.
  5. Click Open when VS Code asks.

# Verify you're inside the repo
git status
# Should say: On branch main, nothing to commit
      
Good sign: Explorer shows the space-invaders folder with README.md inside.
Good sign: Source Control sidebar shows the branch name.
Stop here if: VS Code does not recognize the folder as a Git repository. Fix this before writing any code.

Step 3 — Open Copilot Chat

With the repo open, launch Copilot Chat:

  • Click the Copilot Chat icon in the sidebar, or
  • Press Ctrl+Alt+I (Windows/Linux) or Cmd+Opt+I (Mac)
Copilot Chat can see the files you have open in the editor. When you ask it to create or edit a file, make sure the relevant file is open so Copilot reads its current content — not a guess about what might be in it.
Two Copilot modes — reminder
ModeBest for
InlineSmall local edits, completing obvious next lines
ChatFull 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.

Part 4

The Five Seed Prompts, Layer by Layer

The prompts follow MVC order deliberately. Build the skeleton first, fill each layer in isolation, then verify the Model with a plain-Java tester.

Prompt 1: The MVC Skeleton

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.

Why this first

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.

What to verify
  • Three separate files exist
  • GameModel has no Swing imports
  • A blank window opens
  • No extra classes or logic were added

Prompt 2: Building the Model

Fill 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."

Prompt 3: Building the View

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.

A useful check: after pasting this output, search 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.

Prompt 4: Wiring the Controller

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.

Timer at 16ms ≈ 60 frames per second. This is the game loop.
KeyListener on the frame, not the panel, because the frame receives focus by default.

Prompt 5: Basic Model Testing

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.

Why this prompt matters

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.

What to verify
  • ModelTester compiles with GameModel
  • All five checks print PASS
  • No Swing imports in ModelTester
  • Tests call real model methods, not stubs

After the Seed Prompts: Commit the Baseline

flowchart LR A[Prompt 1\nSkeleton] --> B[Run + verify] B --> C[git commit] C --> D[Prompt 2\nModel] D --> E[Run + verify] E --> F[git commit] F --> G[Prompt 3\nView] G --> H[Run + verify] H --> I[git commit] I --> J[Prompt 4\nController] J --> K[Run full game] K --> L[git commit] L --> M[Prompt 5\nModelTester] M --> N[All PASS] N --> O[git commit]
One commit per prompt. If Prompt 4 breaks something, you can return to the Prompt 3 commit. That is the point of version control.
Part 5

How MVC Makes Agentic AI Prompts More Precise

The design pattern and the prompting practice reinforce each other. A clear architecture produces a clearer conversation with the AI.

Three Prompts for Going Beyond the Seed

Adding shields
In 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.
Drawing shields
In 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.
Increasing speed
In 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.
Part 6

What Software Testing Is and Why It Matters

Testing is the practice of writing code that checks other code. It is especially valuable when working with AI-generated output.

Part 7

Building a ModelTester Class in Plain Java

No new libraries, no setup. Just a main method, a few helper methods, and the same GameModel your game already uses.

The ModelTester Pattern


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.");
    }
}
  
check is the only new concept here. Everything else is Java you already know: static fields, if, System.out.println, method calls.

Writing the Test Methods


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)
}
  

Running ModelTester

Compile and run from the terminal exactly like any other Java file:


javac GameModel.java ModelTester.java
java ModelTester
      
Compile error before any output? Check that every method name in ModelTester matches what the AI actually wrote in GameModel. A 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.
      
What a failure looks like

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.

Note: your output will differ based on which tests you wrote and what the AI named its methods. That is expected.
Part 8

Testing the Model Without Swing — Five Behaviors

These tests check real game logic. Each one would be tedious or impossible to verify just by watching the game.

Five Behaviors Worth Checking


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());
}
      
Why each check matters
  • Alien count — grid bookkeeping; easy to get the off-by-one wrong
  • Score increase — confirms the Model updates state, not the View
  • Game-over flag — you cannot easily see this by playing; you'd need to destroy all 55 aliens manually
  • Boundary clamping — the player wrapping or going off-screen is a common AI mistake
  • Bullet uniqueness — double-firing is invisible at 60fps but causes bugs later

What to Do When a Check Fails

Step 1 — Read the output

Is it a compile error (cannot find symbol) or a runtime FAIL (the check ran but the condition was false)?

Compile error → naming mismatch. Fix the method name in the test or ask Copilot to add the missing getter to the Model.
Step 2 — Is the check itself wrong?

Re-read the condition. Did you write == 3 when the model starts with 0? Fix the test and re-run before touching the Model.

Step 3 — Find the Model method

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?"

Step 4 — Apply, re-run, commit

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.

A 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.
Part 9

What JUnit Is — and Why You Don't Need It Yet

JUnit does the same thing ModelTester does, with more automation. Knowing the concept first makes the library easier to learn later.

Part 10

Debugging Strategies When the AI Is Wrong

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.

Reading a Stack Trace


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)
  
What to read
  • The exception type: NullPointerException
  • The message: playerBullet is null
  • Your code in the trace: GameModel.java:87
Useful follow-up prompt
In GameModel.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.
Part 11

Extensions and What Comes Next

Once the baseline runs and the core tests pass, extensions are more controlled because the architecture is in place.

Advanced Extensions — Expect Iteration

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.

Alien formation animation

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.

Multiple waves

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.

Difficulty scaling

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.

Sound effects

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.

For any advanced extension: describe the expected behavior precisely, ask Copilot for a first attempt, run it, then report exactly what went wrong. Repeat. The conversation log in PROMPTS.md is especially valuable here.

Related Projects

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.

Pac-Man — Atari era

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.

Centipede — Atari era

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.

Tetris — 8-bit era

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.

Donkey Kong — 8-bit era

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.

Frogger — Atari/8-bit

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.