Lecture W5R1: Forced Transitions and Game States
September 24, 2026
LECTURE W5R1 // MACHINES ALL THE WAY UP
Housekeeping: Lab 3 is due Thursday, October 1, 9:59 PM. The project proposal is due Thursday, October 8, 9:59 PM. Both close at 9:59 PM sharp.
2 minutes with a neighbor:
git checkout demo2, accept the reload prompt, run.Tuesday on one index card:
| From | When | To |
|---|---|---|
| PATROL | Blitz closer than 260 | CHASE |
| CHASE | Blitz farther than 360 | PATROL |
Rules live in rooms; entry effects live in one door. Today the table grows twice.
Everything so far, the rival decided for itself: it saw you, it lost you. The fig is different. Something else decides the rival’s mood:
| From | When | To |
|---|---|---|
| PATROL | Blitz closer than 260 | CHASE |
| CHASE | Blitz farther than 360 | PATROL |
| any state | Blitz eats a fig | FRIGHTENED |
| FRIGHTENED | 3 seconds pass | PATROL |
Two new kinds of exit: a forced transition (an outside event knocks on the door) and a timed exit (a room you leave on a clock, no input required).
On branch demo3, rival.gd grows. Three declarations up top:
Fleeing is chasing with a minus sign. The clock counts in seconds (delta is time), so fear lasts 3 seconds on any machine, fast or slow.
Three movement lines, one per room, in rival.gd:
velocity = direction.normalized() * speed. Subtracting two positions gives a direction; normalized() sets its length to 1, so distance never leaks into speed; move_and_slide() does the actual moving.
One line wearing three tints. The state machine only picks which direction and which speed.
Still rival.gd, inside _change_state: the door learns to dress the new mood, and below it, a doorbell anyone outside can ring:
frighten() is the rival’s public API: reset the clock, walk through the door. Outside code never pokes state directly; it asks.
A snack that frightens rivals, built click by click:
Fig. An Area2D senses overlaps; it never blocks anyone.assets/fig.png; child CollisionShape2D with a CircleShape2D, radius 14.snacks only, Mask = player only. The fig notices Blitz and nobody else.fig.tscn, then instance it in main.tscn around (576, 500).fig.gd, all of it:
A group is a name you stamp on nodes in the Node dock. call_group("rivals", "frighten") calls frighten() on every node in the group. The fig never learned the rival’s address.
The rival scene already carries the rivals stamp. When Pac-Blitz has four rivals, this line frightens all four. Zero changes.
git checkout demo3, reload, run.rival: FRIGHTENED, then rival: PATROL. The door narrates every forced entry too.A forced transition is a public method that walks through the same door as every other transition. One door means the tint, the print, and every future entry effect stay correct no matter who caused the change.
Your game right now: the instant it launches, gameplay is running. Every real game wraps gameplay in screens, and screens are a state machine wearing a different costume:
| From | When | To |
|---|---|---|
| TITLE | player presses accept | PLAYING |
| PLAYING | rival touches Blitz | CAUGHT |
| CAUGHT | player presses accept | TITLE |
Same pattern, one level up: the rival’s brain picks moods, the game’s brain picks screens. The rooms just got bigger.
On branch demo4, a new script on the Main node: game.gd. (catch_distance is an @export, 36 by default.)
enum State { TITLE, PLAYING, CAUGHT }
var state: State = State.TITLE
func _physics_process(_delta: float) -> void:
match state:
State.TITLE:
if Input.is_action_just_pressed("ui_accept"):
_change_state(State.PLAYING)
State.PLAYING:
if player.global_position.distance_to(
rival.global_position) < catch_distance:
_change_state(State.CAUGHT)
State.CAUGHT:
if Input.is_action_just_pressed("ui_accept"):
_change_state(State.TITLE)The interesting work is entry effects, in game.gd:
func _change_state(new_state: State) -> void:
state = new_state
var running := new_state == State.PLAYING
player.set_physics_process(running)
rival.set_physics_process(running)
match new_state:
State.TITLE:
player.position = player_start
rival.position = rival_start
rival.calm()
banner.text = "RIVAL BRAINS\nPress Space to play"
banner.visible = true
State.PLAYING:
banner.visible = false
State.CAUGHT:
banner.text = "CAUGHT!\nPress Space to try again"
banner.visible = trueset_physics_process(false) freezes a node’s _physics_process without deleting anything. Two lines pause the whole world; the machines below never know.rival.calm() rings the rival’s other doorbell (same shape as frighten(), walking it back to PATROL).game.gd never touches rival.state.ui_accept is Godot’s built-in menu action, and menus are exactly what the built-ins are for. Custom actions stay reserved for gameplay.git checkout demo4 (or main, they match), reload, run.Machines all the way up: rival moods at the bottom, game screens at the top, the same three tools at every level. Enum, match, one door. Your project ships at least one of each, and your Lab 3 M5 twist is probably a new room in one of them.
Your team just got hired to build Whac-A-Mole. The client’s spec, in full:
6 minutes, with your teammate, on paper:
A table we would accept (yours may differ, and that is fine):
| From | When | To |
|---|---|---|
| HIDDEN | pop timer expires (random wait) | RISING |
| RISING | fully out of the hole | UP |
| UP | stay timer expires | HIDDEN |
| UP | mallet hits (forced, from outside) | BONKED |
| BONKED | ouch animation ends | HIDDEN |
The mallet rings a doorbell: whack() is to the mole what frighten() is to the rival. And the point is scored in the door: entering BONKED awards the point, plays the sound, swaps the sprite. One door means the score stays right whoever causes the hit.
get_tree().call_group("moles", "retreat"). Same broadcast as the fig.match, one door. Your proposal’s tables are next.Your first milestone gate: 400 points, 4% of the course grade, as PROPOSAL.md in your team repo (full rubric on Canvas). It must contain:
Rest of class, with your teammate. In priority order:
PROPOSAL.md. Argue about scope now; it is cheaper than in November.frighten(), calm()) that funnels through the same _change_state door as every internal exit, so entry effects stay correct whoever causes the change.get_tree().call_group("rivals", "frighten") scales from one rival to four for free.set_physics_process as the pause switch and ui_accept driving the menus.state.Lab 3: Platformer is due Thursday, October 1, 9:59 PM: pushed to main, Your Submission section complete.
Project proposal is due Thursday, October 8, 9:59 PM: PROPOSAL.md in your team repo; today’s transition tables go straight in.
Reading: finish GPP: State; the ending on hierarchical machines is today in two paragraphs.
Next week: Tuesday, components and data-driven design. Thursday, git for teams and lightning pitches: Lab 3 due, Lab 4 out.
demo3 (forced transitions), demo4 (game states).