Objects & classes: state and behaviour move in together
420-302-VA · WEEK 6 · FALL 2026

Stage 5 of 6 · Lab, kit and Pi · about 50 minutes

Build lab

Same circuit, same spec, new architecture. You will rebuild Week 5's reaction timer as two classes and a module, then prove the rebuild changed structure without changing behaviour, by re-running the very verification table you wrote last week. That move has a professional name, and you are about to earn it.

The plan: a refactor, with proof

Refactoring is improving a program's structure while preserving its behaviour. It is half of real software work, systems are grown, then reshaped, and it is only safe when "behaviour preserved" can be demonstrated. You are unusually well equipped: Week 5 left you a spec (R1 to R6) and a verification table, which together are a behaviour contract. Today's rule: the spec does not change, the checks do not change, only the shape of the code does, and the table must pass identically afterward. Circuit as in Week 4/5: LED on GPIO17 (physical 11), button on GPIO27 (physical 13), wired with outputs off. Project setup:

$ mkdir -p ~/iot/week6 && cd ~/iot/week6
$ python3 -m venv --system-site-packages .venv && source .venv/bin/activate
$ nano .gitignore        # .venv/ __pycache__/ *.pyc, as always

Paper first, ten minutes, Week 5 discipline: draw the two capsules before typing, ReactionLog (attributes: player, times; methods: add, count, best, average, summary) and ReactionGame (attributes: led, button, log; methods: wait_delay, play_round, play). Commit the drawing's photo or a Markdown sketch to docs/. Class boxes with attributes above and methods below are the standard way software teams draw designs; yours can be exactly that simple.

Your first module

ReactionLog is hardware-free and reusable, so it deserves its own file. Any .py file is a module, importable by its file name; you have imported other people's since Week 4, and today you import your own. Create reactionlog.py containing the finished class from First class (empty-safe best() and average() included, per L4) plus one convenience:

    def summary(self):
        if not self.times:
            return f"{self.player}: no valid rounds"
        return (f"{self.player}: {self.count()} rounds, "
                f"best {self.best():.0f} ms, average {self.average():.0f} ms")

Then prove the module works before any hardware is involved:

$ python3
>>> from reactionlog import ReactionLog
>>> log = ReactionLog("Sam"); log.add(431); log.add(388)
>>> print(log.summary())
Sam: 2 rounds, best 388 ms, average 410 ms

Two mechanics to notice. The import names the file, minus .py, and works because you launched Python in the same folder. And a __pycache__/ directory appears after the import: Python caching the compiled module, machine-made clutter, which your Week 4 .gitignore already keeps out of the repository. Testing a class in the REPL like this, before it meets the rest of the system, is a habit worth keeping: small capsule, small test, then compose.

The ReactionGame class

Now the device class, in reaction_game.py, translated from Week 5's traced design with the state-and-devices patterns:

from gpiozero import LED, Button
from time import monotonic, sleep
from random import uniform
from reactionlog import ReactionLog       # your module

class ReactionGame:
    def __init__(self, led_pin, button_pin, player):
        self.led = LED(led_pin)                          # has-a
        self.button = Button(button_pin, bounce_time=0.05)
        self.log = ReactionLog(player)                   # has-a, of your own making

    def wait_delay(self):
        """The watchful wait. Returns True on a false start."""   # R2, R4
        delay = uniform(2.0, 5.0)
        waited = 0.0
        while waited < delay:
            if self.button.is_pressed:
                return True
            sleep(0.01)
            waited = waited + 0.01
        return False

    def play_round(self):
        """One round. Returns the time in ms, or None on a false start."""
        self.led.off()                                   # R1
        print("Get ready...")
        if self.wait_delay():
            print("FALSE START")                         # R4
            self.button.wait_for_release()
            return None
        self.led.on()                                    # R2
        t0 = monotonic()
        self.button.wait_for_press()                     # R3
        ms = (monotonic() - t0) * 1000
        self.led.off()
        print(f"Reaction: {ms:.0f} ms")
        self.button.wait_for_release()
        self.log.add(ms)
        return ms

    def play(self, rounds):
        """Play until `rounds` valid times are recorded."""       # R6's successor
        while self.log.count() < rounds:
            self.play_round()
        print(self.log.summary())

game = ReactionGame(17, 27, "Sam")
game.play(5)

Read the refactor's moves, because naming them is how the lesson sticks:

  • A flag became a return value. Week 5's false_start variable existed to carry a fact out of the wait loop; once the wait is its own method, the fact rides out as return True. Extracting methods often dissolves flags, one of refactoring's most common small joys.
  • Loose state found owners. The hardware lives on the game; the scores live on the log; the log lives on the game. game.log.best() reads left to right through the composition, and nothing is global.
  • The main program became two lines, which is what makes the next changes cheap: a second player, a different pin, ten rounds instead of five, each is an argument, not a rewrite.
  • Docstrings arrived. The triple-quoted first line of a def is its documentation; help(ReactionGame) now shows your own words, exactly as it shows gpiozero's. Small habit, large courtesy.

Verification: the refactoring proof

Open Week 5's docs/requirements.md, copy the verification table into this week's docs/, and run every check again against the class version: R1 to R4 and R6 by the same bench procedures, R5 by the same error-budget argument (nothing in the timing path changed). Add one row for the new behaviour, the end-of-game summary, with its own acceptance check. If every old row passes as before, you have a proven refactor; if a row fails, the refactor introduced a behaviour change, and Week 5's layer ladder finds it, most often at C, a translation slip between the design and the class. Commit the table with a message that says what it proves: refactor to classes; W5 verification re-run, all pass.

The class-design ladder

Spec first, Week 5 protocol, navigator and driver swapping each rung: for every rung, 3 to 5 requirements with acceptance criteria, a class sketch (attributes/methods box, plus a state diagram where modes exist), a one-path trace, then code and a verification table. The new design question each spec must answer: which class owns which state?

  1. Chime

    A Chime class: each press triggers exactly one double-flash; presses during the flash are ignored. Your Week 5 rung, reborn as a capsule: the "busy" fact becomes an attribute, the press handler a method. Spec question: where does "busy" get set and cleared?

  2. NightLight, typed and extended

    Type the worked NightLight, verify it, then extend per your own spec: a third mode DIM (LED blinking slowly) in the press cycle. The state diagram grows a circle; the class grows a branch. Trace the full cycle before running.

  3. Lockout, the class way

    Extend ReactionGame: three false starts in one game triggers a 10 s LOCKED period, LED flashing, presses ignored, then play resumes. The counter and the mode are attributes begging to exist; write their R-lines, then let play_round consult them.

  4. Two players

    One game, two ReactionLog instances, alternating valid rounds, winner announced from the two summaries. Instance independence does the heavy lifting; your spec decides what alternation means when someone false-starts.

  5. Stretch: Scoreboard

    A Scoreboard class owning both logs (composition of composition): record(player, ms), leader(), report(). Pure Python, REPL-testable, and a preview of how the project will keep telemetry tidy.

Checklist for this stage

Check yourself

Define refactoring, and state what made today's refactor proven rather than hoped.
Changing structure while preserving behaviour. The proof was re-running Week 5's verification table, the behaviour contract, and getting identical results; without such checks, "it still seems to work" is hope, not proof.
Where did Week 5's false_start flag go?
It became wait_delay()'s return value. Once the watchful wait was extracted into its own method, the fact it discovered could ride out through return, and the flag variable had no job left.
Why test reactionlog.py in the REPL before writing the game?
Small capsule, small test: a hardware-free class can be proven correct in isolation, so any later fault is known to live in the game or the wiring between them. Composing tested parts is what makes systems debuggable.
What does from reactionlog import ReactionLog require of your files and your working directory?
A file named reactionlog.py defining ReactionLog, in the directory Python was launched from. The import names the file minus .py; a name mismatch or the wrong folder gives ModuleNotFoundError.
In game.log.best(), walk the dots.
Left to right: game is a ReactionGame instance; .log reaches its ReactionLog attribute (composition); .best() calls that log's method. Each dot steps one capsule deeper.
For the lockout rung, which class should own the false-start counter, and why?
The game: the counter is game-session state governing game behaviour, consulted and reset by the game's own methods. Putting it on the log would tangle scorekeeping with control, and putting it in the main program would resurrect exactly the loose state today abolished.