Blog Tutorial

Part 7 — Handle out-of-bounds and lives

In this part, we will add the floor trigger and the first version of the level controller.

The floor does not bounce the ball. It detects when the ball falls below the paddle and emits an event. The level controller listens for that event and subtracts a life.

Add Tags and Events

Create two small enums under Assets/Scripts/Enumeration.

<?php

declare(strict_types=1);

namespace BreakOut\Game\Scripts\Enumeration;

enum Tag: string
{
    case Ball = 'Ball';
    case Floor = 'Floor';
    case Brick = 'Brick';
}
<?php

declare(strict_types=1);

namespace BreakOut\Game\Scripts\Enumeration;

enum EventType: string
{
    case BALL_WENT_OUT_OF_BOUNDS = 'game.ballWentOutOfBounds';
    case GAME_OVER = 'game.over';
    case BRICK_DESTROYED = 'game.brickDestroyed';
    case GAME_WON = 'game.won';
}

We are adding a couple of events early because the level controller will grow into them over the next few parts.

Create the Floor Trigger

Under Boundaries, create a wide rectangle below the paddle and rename it Floor.

Add or confirm these components:

  • Rectangle Renderer, so you can see it while positioning
  • BoxCollider2D

Set the BoxCollider2D as a trigger and set the GameObject tag to Floor.

Also set the ball's tag to Ball.

Add the Floor Script

Create Assets/Scripts/Floor.php:

<?php

declare(strict_types=1);

namespace BreakOut\Game\Scripts;

use BreakOut\Game\Scripts\Enumeration\EventType;
use BreakOut\Game\Scripts\Enumeration\Tag;
use Lenga\Engine\Attributes\AddComponentMenu;
use Lenga\Engine\Core\Behaviour;
use Lenga\Engine\Core\Collision2D;

#[AddComponentMenu('Gameplay/Floor')]
final class Floor extends Behaviour
{
    public function onTriggerEnter2D(Collision2D $collision): void
    {
        if ($collision->gameObject?->tag !== Tag::Ball->value) {
            return;
        }

        $this->emitEvent(EventType::BALL_WENT_OUT_OF_BOUNDS->value, [
            'ball' => $collision->gameObject,
            'collision' => $collision,
        ]);
    }
}

Attach it to Floor.

The floor only reports the event. It does not reset the ball or change lives directly.

Grow the Ball Script

In Ball.php, add this method:

public function onEnable(): void
{
    $this->onEvent(EventType::BALL_WENT_OUT_OF_BOUNDS->value, fn (): bool => $this->resetBall());
}

Then import the event enum:

use BreakOut\Game\Scripts\Enumeration\EventType;

Now the ball resets itself when the floor reports it went out of bounds.

Grow the Paddle Script

The paddle also needs to become launch-ready again.

Add this to Paddle.php:

public function onEnable(): void
{
    $this->onEvent(EventType::BALL_WENT_OUT_OF_BOUNDS->value, fn (): bool => $this->resetPaddle());
}

private function resetPaddle(): bool
{
    $this->canLaunch = true;
    $this->transform->position = clone $this->startPosition;

    if ($this->body instanceof Rigidbody2D) {
        $this->body->velocity = Vector3::zero();
    }

    return true;
}

Then import EventType:

use BreakOut\Game\Scripts\Enumeration\EventType;

onEvent tracks the subscription for the Behaviour, so it is cleaned up when the Behaviour is disabled.

Add the Level Controller

Create an empty GameObject called Level Manager, then create Assets/Scripts/LevelController.php:

<?php

declare(strict_types=1);

namespace BreakOut\Game\Scripts;

use BreakOut\Game\Scripts\Enumeration\EventType;
use Lenga\Engine\Attributes\AddComponentMenu;
use Lenga\Engine\Attributes\Header;
use Lenga\Engine\Core\Behaviour;

#[AddComponentMenu('Level/Level Controller')]
final class LevelController extends Behaviour
{
    #[Header('Gameplay')]
    public int $lives = 3;

    public function onEnable(): void
    {
        $this->onEvent(EventType::BALL_WENT_OUT_OF_BOUNDS->value, fn (): bool => $this->loseLife());
    }

    private function loseLife(): bool
    {
        $this->lives = max(0, $this->lives - 1);

        if ($this->lives === 0) {
            $this->emitEvent(EventType::GAME_OVER->value);
        }

        return true;
    }
}

Attach LevelController to Level Manager.

For now, lives are just data on the controller. We will display them in the HUD later.

Checkpoint

At the end of this part:

  • the floor is a trigger below the paddle
  • the floor emits an out-of-bounds event for the ball
  • the ball resets after crossing the floor
  • the paddle becomes launch-ready again
  • LevelController tracks lives

Next, we will build the brick field.