Collision Course

Lecture W2R1: Input, Real Movement, and the Physics Conversation

Lucas P. Cordova, Ph.D.

Willamette University

September 3, 2026

Insert Coin

Today’s agenda

LECTURE W2R1 // COLLISION COURSE

  1. Paddle pulse (2 minutes): how’s your Lab 1 coming along?
  2. Input, done right: custom actions, and one line that replaces the whole if/elif ladder.
  3. Real movement: velocity + move_and_slide(), on the physics clock, into actual walls.
  4. The physics conversation: collision layers and masks. The ghost stops eating our marionberries.

Housekeeping: Lab 1 is due Thursday, September 10, 9:59 PM. Push as you go.

Paddle pulse

2 minutes with your neighbor:

  1. Which milestone are you on?
  2. One blocker, if you have one.
  3. One thing you discovered worth sharing (or a question you have).

Where Pac-Blitz stands

Blitz moves, faces, eats; the count climbs; the ghost wanders, and two things are wrong on purpose:

  • Movement shoves position around in _process: no physics, no walls, and the wrong clock the moment collisions matter.
  • The berries do not care who touches them. body_entered fires for any body, so the ghost eats our marionberries.

Follow along in your own Pac-Blitz copy. We live-code from main (the before state), and every finished step is a checkpoint tag on the W2R1-demo branch:

git checkout main         # where the demo starts: no actions, no walls yet
git checkout W2R1a        # fell behind? jump to any finished checkpoint

Some git notes

Switching branches with Godot open? Scenes and scripts reload from disk, but Project Settings do not: the editor keeps its cached Input Map and layer names, and will happily save them back over your checkout. After any checkout that touches project.godot: Project → Reload Current Project (and do not save before you do).

Input, Done Right

Training wheels be gone

The ui_left family got us through week one. It was never meant for gameplay:

  • ui_* actions belong to menus. They are what the engine uses to navigate buttons and lists.
  • Games declare intent: move_left, dash, chomp. The action names what the player means, not which key they pressed.
  • One action can hold many inputs. Arrows, WASD, and a gamepad stick can all be bound without touching a line of code.

An input action is a named abstraction over physical inputs. Your code should ask “does the player want to move left?” and never “is the A key down?”

Make the actions

  1. Project → Project Settings → Input Map tab.
  2. In Add New Action, type move_left, click Add.
  3. Click the + next to the new action, press the Left Arrow key, click OK.
  4. Click + again, press A, click OK. Two bindings, one intent.
  5. Repeat for move_right (Right, D), move_up (Up, W), move_down (Down, S). Close the dialog.

The actions live in project.godot. Your input scheme is part of the game, exactly like a scene or a script. (Do this on main, where no actions exist yet. On the demo branch they are already there, and Godot will refuse the duplicate names. Finished version: tag W2R1a.)

One line replaces the ladder

Open blitz.gd. The entire if/elif ladder from week one:

    var direction := Vector2.ZERO
    if Input.is_action_pressed("ui_left"):
        direction = Vector2.LEFT
    elif Input.is_action_pressed("ui_right"):
        direction = Vector2.RIGHT
    elif Input.is_action_pressed("ui_up"):
        direction = Vector2.UP
    elif Input.is_action_pressed("ui_down"):
        direction = Vector2.DOWN

The new one-liner

Becomes:

    var direction := Input.get_vector(
        "move_left", "move_right", "move_up", "move_down")
  • get_vector reads four actions as one Vector2. Last week’s ladder could not even do diagonals; this gets them for free!
  • Diagonals arrive normalized: (0.707, 0.707), not (1, 1). No diagonal speed cheat. This is a bug most first games ship.
  • Plug in a controller and the same line reads the analog stick, including how far it is pushed. Zero new code.

The match casualty

One thing breaks blitz.gd: _update_facing pattern-matched exact values (Vector2.LEFT), and a diagonal is neither left nor up (it is both). A continuum needs logic. Replace the whole function with:

func _update_facing(direction: Vector2) -> void:
    var sprite := $Sprite2D as Sprite2D
    if absf(direction.x) > absf(direction.y):
        sprite.flip_h = direction.x < 0
        sprite.rotation_degrees = 0
    else:
        sprite.flip_h = false
        sprite.rotation_degrees = -90 if direction.y < 0 else 90

Dominant axis wins: mostly-horizontal means flip, mostly-vertical means tilt. match is for shapes of values; thresholds are for ranges.

Real Movement

The body wakes up

CharacterBody2D has been waiting since day one. It owns a velocity, and it knows how to move. Back in blitz.gd, _process becomes:

func _physics_process(delta: float) -> void:
    var direction := Input.get_vector("move_left", "move_right", "move_up", "move_down")
    velocity = direction * speed
    move_and_slide()

    if direction != Vector2.ZERO:
        _update_facing(direction)
  • velocity is a built-in property of the CharacterBody2D class that we extended.
  • move_and_slide() consumes it: moves the body, detects collisions, resolves them. The physics engine now owns motion. So the code lives in _physics_process.
  • The clamp line is gone. Something better is about to hold Blitz in.

Where did * delta go?

Nowhere: it moved inside. velocity is still pixels per second; move_and_slide() multiplies by the physics tick’s delta for you.

This means: position += velocity * delta and move_and_slide() together is double movement. You need one owner of motion per body. The moment the physics engine takes over, your arithmetic goes away.

Meet the immovable object

Bodies so far: CharacterBody2D (moves, collides) and Area2D (senses, never blocks). The third archetype is StaticBody2D: never moves, never senses. It just is. Perfect for walls.

  1. Scene → New Scene, root: Other Node → StaticBody2D, renamed Walls.
  2. Add a CollisionShape2D child, Shape → New RectangleShape2D, size it into the top border (about 1152 x 24, centered at the top).
  3. Walls are invisible by default. Add a ColorRect child, sized to match, so you can see it.
  4. Repeat for bottom, left, and right, plus a bar or two in the open field for something to bump.
  5. Save as walls.tscn, instance it into main.tscn (chain-link icon), save, run.

Run it: Blitz stops

Blitz hits the wall and stops. That is our first real collision: the physics engine noticed two shapes, refused the overlap, and resolved it.

Now hold a diagonal into a wall: Blitz slides along it instead of sticking. That is the “slide” in move_and_slide. the engine cancels the into-wall part of your velocity and keeps the rest. Every 2D game that feels good does exactly this; you get it for free.

One problem left. Run through the berry row… and watch the ghost do the same. It still eats everything.

The Physics Conversation

The crime scene, revisited

The berry’s whole brain (unchanged):

func _on_body_entered(body: Node2D) -> void:
    eaten.emit()
    queue_free()
  • body_entered fires for any physics body. Bearcat, ghost, falling piano: the berry cannot tell.
  • We could write if body.name == "Blitz" (identity checks in code). It works… until there are four rivals, figs, and a maze of moving parts, and every object interrogates every other.
  • Physics engines have a better idea: decide who can interact, before any code runs.

Layers and masks

Every collision object carries two bitmasks. collision_layer answers “what am I?” (which groups I belong to). collision_mask answers “what do I notice?” (which groups I am scanning for). Contact only exists when one object’s mask meets another’s layer.

First, name the groups so the checkboxes mean something:

Project → Project Settings → General, search “layer names”Layer Names → 2D Physics: name layer 1 world, 2 player, 3 rivals, 4 snacks.

Assign the conversation

Select each scene’s root node, and in the Inspector find Collision:

Node Layer (“I am…”) Mask (“I notice…”) In English
Blitz player world Walls stop me. Berries are not my problem; I am theirs.
Ghost rivals world I am a rival. (I float through walls for now. I am a ghost.)
Berry snacks player I am a snack, and I only notice the player.
Walls world - I am the world. I notice nothing. I outlast you all.

That single player-only mask on the berry is the entire fix. (W2R1c)

Justice

Run it. The ghost drifts straight through the berry row, and the berries survive. The rules moved into data: checkboxes the physics engine reads before your script ever runs.

The gap ahead

Remember Blitz’s four class years? First-Year fits through crawlspaces the big kids cannot. You now hold both halves of that mechanic: a crawlspace is a gap in the world layer, and “fits” is nothing more than a smaller CollisionShape2D. Growth stages swap shape data (same masks, same code).

Take It Home

The checkpoints

Today’s work lives on the W2R1-demo branch, and each checkpoint is a git tag you can jump to by name:

git checkout W2R1-demo    # the finished state (same as W2R1c)
git checkout W2R1a        # or jump straight to any checkpoint
Tag State
W2R1a Input actions + get_vector; Tuesday’s probes retired
W2R1b velocity + move_and_slide(); walls up, clamp gone
W2R1c Layers and masks: the ghost goes hungry (the branch tip)

Some git notes and warning reminder

Two git lessons ride along: checking out a tag leaves you “detached” from any branch (sightseeing, not building); git checkout W2R1-demo brings you home. And git show W2R1b prints exactly what that step changed.

Switching branches with Godot open? Scenes and scripts reload from disk, but Project Settings do not: the editor keeps its cached Input Map and layer names, and will happily save them back over your checkout. After any checkout that touches project.godot: Project → Reload Current Project (and do not save before you do).

Now, on your paddle game

Your Lab 1 keeps its arithmetic on purpose (nothing physical has taken over there). But two of today’s tools transfer:

  • Building the second player (M5)? Its keys are custom Input Map actions: exactly today’s recipe, just with W and S.
  • Ball ignoring your paddle? You now know the two questions to ask any collision that will not happen: whose layer, whose mask?

Game Over Screen

What you know now

  • Input actions name intent, hold many bindings, and make Input.get_vector a one-line replacement for the ladder, with normalized diagonals free.
  • velocity + move_and_slide() put movement on the physics clock; the body applies delta itself, and one owner of motion per body.
  • StaticBody2D completes the archetype set: moves-and-collides, senses, just-is.
  • Layers say what you are; masks say what you notice. Collision rules are data: the berry never changed its code, and the ghost went hungry anyway.

Quest log

Lab 1: Paddle Game is due Thursday, September 10, 9:59 PM: game pushed to main, Your Submission section filled: screenshot, your custom feature, your reflections.

Reading for next week (highly recommended): Godot: Scene organization. It is the best-practices version of the thing you have been doing by instinct.

Next week: scenes as reusable objects: composition, instancing, and how big games stay sane. Then the marionberry count finally reaches the screen: signals.

References

Sources

  1. Godot Engine documentation: Input examples, Using CharacterBody2D, and Physics introduction (layers and masks).
  2. Class Pac-Blitz repo, branch W2R1-demo: checkpoint tags W2R1a, W2R1b, W2R1c.