Machines All the Way Up

Lecture W5R1: Forced Transitions and Game States

Lucas P. Cordova, Ph.D.

Willamette University

September 24, 2026

Insert Coin

Today

LECTURE W5R1 // MACHINES ALL THE WAY UP

  1. Lab 3 pulse (2 minutes): where is your Bearcat?
  2. The fig strikes back (live): a transition forced from outside the brain.
  3. Machines all the way up (live): the game itself gets title and game-over screens.
  4. Boss fight (co-op): I hand you a game, you wire its brain.
  5. The proposal gate: what is due Thursday, October 8, and how it is graded.
  6. Team sprint time: start writing the proposal with me in the room.

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.

Lab 3 pulse

2 minutes with a neighbor:

  1. Which milestone are you on? (M1 body, M2 brain, M3 poses, M4 world, M5 twist)
  2. One thing that surprised you or bit you.
  3. One question for me before studio time.

Where We Left Off

The rival, Tuesday night *

  1. In your rival-brains clone: git checkout demo2, accept the reload prompt, run.
  2. White rival walks its route. Cross the sight range: magenta, and it hunts. Outrun it: white again.

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.

The Fig Strikes Back

A transition from outside

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

The new room *

On branch demo3, rival.gd grows. Three declarations up top:

enum State { PATROL, CHASE, FRIGHTENED }

@export var flee_speed: float = 260.0
@export var frightened_time: float = 3.0

var frightened_left: float = 0.0
        # ...and one new arm in the match:
        State.FRIGHTENED:
            frightened_left -= delta
            velocity = -to_player.normalized() * flee_speed
            if frightened_left <= 0.0:
                _change_state(State.PATROL)

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.

One formula, three moods *

Three movement lines, one per room, in rival.gd:

# PATROL: toward the next waypoint
velocity = (target - global_position).normalized() * patrol_speed
# CHASE: toward Blitz
velocity = to_player.normalized() * chase_speed
# FRIGHTENED: away from Blitz (the minus sign flips the direction)
velocity = -to_player.normalized() * flee_speed

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.

The door grows too

Still rival.gd, inside _change_state: the door learns to dress the new mood, and below it, a doorbell anyone outside can ring:

    match new_state:
        # PATROL and CHASE arms unchanged...
        State.FRIGHTENED:
            modulate = Color("478cbf")

func frighten() -> void:
    frightened_left = frightened_time
    _change_state(State.FRIGHTENED)

frighten() is the rival’s public API: reset the clock, walk through the door. Outside code never pokes state directly; it asks.

Build the fig with me

A snack that frightens rivals, built click by click:

  1. New scene, root Area2D, named Fig. An Area2D senses overlaps; it never blocks anyone.
  2. Child Sprite2D with assets/fig.png; child CollisionShape2D with a CircleShape2D, radius 14.
  3. Inspector, Collision: Layer = snacks only, Mask = player only. The fig notices Blitz and nobody else.
  4. Node dock, connect body_entered to the root. Save as fig.tscn, then instance it in main.tscn around (576, 500).

The fig’s whole script

fig.gd, all of it:

extends Area2D

func _on_body_entered(_body: Node2D) -> void:
    get_tree().call_group("rivals", "frighten")
    queue_free()

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.

Run it

  1. git checkout demo3, reload, run.
  2. Lead the rival on a chase, then grab the fig: the rival flashes Godot blue and runs away for 3 seconds, then walks back to its route.
  3. Watch the Output panel: 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.

Machines All the Way Up

Screens are states

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.

The game’s brain

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 game’s door

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 = true

Reading that door

  • set_physics_process(false) freezes a node’s _physics_process without deleting anything. Two lines pause the whole world; the machines below never know.
  • Entering TITLE resets the board: positions restored, and rival.calm() rings the rival’s other doorbell (same shape as frighten(), walking it back to PATROL).
  • One machine commands another only through public methods. 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.

Run it

  1. git checkout demo4 (or main, they match), reload, run.
  2. Title screen. Space to play. Get caught: CAUGHT!, world frozen mid-lunge. Space again: fresh board, calm rival, title screen.

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.

Boss Fight

The cabinet

Your team just got hired to build Whac-A-Mole. The client’s spec, in full:

  • Objective: score as many points as you can in 60 seconds.
  • Moles pop out of holes on their own schedule, wait a moment, then duck back down if nobody whacks them.
  • Whack an up mole: point scored, ouch animation, mole ducks immediately.
  • The mallet is the player. The mole never decides to be whacked; that decision arrives from outside.
  • At 0:00 the cabinet locks up and shows the final score until someone presses start.

Wire the mole

6 minutes, with your teammate, on paper:

  1. Name one mole’s states.
  2. Draw its From / When / To table. You will need at least one timed exit and one forced transition.
  3. Mark the doors. Where does the point get scored?
  4. Fast pair? Draw the cabinet’s brain too.

One mole’s brain

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.

The arguments were the point

  • Whacking a RISING mole: point or no point? The table forces you to decide on paper; the flag-soup version decides by accident at 2 AM.
  • A “clear the board” bonus that ducks every mole at once? get_tree().call_group("moles", "retreat"). Same broadcast as the fig.
  • The cabinet’s brain: ATTRACT, PLAYING, SCORE. PLAYING’s exit is a 60 second timer, which is FRIGHTENED’s exit wearing a suit.
  • Every brain today: an enum, a match, one door. Your proposal’s tables are next.

The Proposal Gate

Thursday, October 8, 9:59 PM

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:

  • The game: concept, the core loop in one breath, aesthetics and fiction.
  • Scope: your 20 Games Challenge anchor, plus MUST / WANT / WON’T lists.
  • MDA plan: mechanics to dynamics, with transition tables for your player controller and one other machine.
  • Roadmap: what exists at each gate, with the vertical slice named.
  • Risks and division of labor, plus your branching and asset plan.

Why the transition tables

  • The proposal asks for the exact artifact from Tuesday: a table per brain. If you can draw the table, the code is a transcription job. If you cannot, that is a design problem, and finding it on paper costs nothing.
  • Your stakeholders are assigned at this gate: two students from other teams who will review your prototype, alpha, and beta all semester. The proposal is the document they meet your game through.
  • Scope wins games. The MUST list should look almost embarrassingly small; demo4 up there is a complete game loop with three states and a label.

Co-op Mode

Team sprint time

Rest of class, with your teammate. In priority order:

  1. Draft your 20GC anchor and MUST / WANT / WON’T lists in a shared doc or straight into PROPOSAL.md. Argue about scope now; it is cheaper than in November.
  2. Draw the transition table for your player controller. States, exits, forced transitions from outside. Steal shamelessly from the rival and from Lab 3.
  3. Anything left, put it toward Lab 3. I am circulating for both.

Game Over Screen

What you know now

  • A forced transition is a public method (frighten(), calm()) that funnels through the same _change_state door as every internal exit, so entry effects stay correct whoever causes the change.
  • Groups broadcast to every stamped node: get_tree().call_group("rivals", "frighten") scales from one rival to four for free.
  • Screens are states. TITLE, PLAYING, CAUGHT is the same enum-match-door pattern one level up, with set_physics_process as the pause switch and ui_accept driving the menus.
  • Machines command machines through public methods, never by reaching into each other’s state.

Quest log

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.

References

Sources

  1. Nystrom, Game Programming Patterns: State.
  2. The rival-brains demo repo: demo3 (forced transitions), demo4 (game states).
  3. Godot docs: Groups and Using Area2D.