Main game coordinator component for Breakout. Manages game flow (menu, serving, playing, game over), tracks score, lives, level, combos, spawning/clearing boards, hit-stop, banners, and delegates ball/level/paddle work to BallManager, LevelDirector, LevelSpawner, GameFeedback and other components.
using System;
using Sandbox;
using Sandbox.Services;
namespace Breakout;
/// <summary>
/// The main game coordinator. It runs the overall flow (menu, serving, playing,
/// game over), tracks score/lives/level, and reacts to gameplay events such as a
/// block breaking or the ball being lost.
/// </summary>
[Title( "Breakout Game" ), Category( "Breakout" ), Icon( "sports_esports" )]
public sealed class BreakoutGame : Component
{
[Property] public Playfield Playfield { get; set; }
[Property] public Paddle Paddle { get; set; }
[Property] public LevelSpawner Spawner { get; set; }
[Property] public GameObject PaddlePrefab { get; set; }
[Property] public int StartingLives { get; set; } = 3;
[Property] public float PaddleZ { get; set; } = -300f;
[Property, Group( "Juice" )] public float BoardClearPause { get; set; } = 1.5f;
[Property, Group( "Classic Mode" )] public float ClassicPaddleShrink { get; set; } = 0.5f;
[Property, Group( "Weekly Challenge" )] public int WeeklyMaxExpandsPerLevel { get; set; } = 2;
private readonly Sandbox.Game.Overlay _overlay = new();
/// <summary>
/// True while the s&box pause menu is open.
/// </summary>
public bool IsPaused => _overlay.IsPauseMenuOpen;
/// <summary>
/// True only while the player is allowed to steer the paddle: during serving or
/// playing, and never while the pause menu is up.
/// </summary>
public bool PaddleControlActive => (State == GameState.Serving || State == GameState.Playing) && !IsPaused;
/// <summary>
/// Which mode is being played (Classic, Arcade or Weekly).
/// </summary>
public GameMode Mode { get; private set; } = GameMode.Classic;
/// <summary>
/// The current mode's name in capitals, for showing on the HUD.
/// </summary>
public string ModeLabel => Mode switch
{
GameMode.Arcade => "ARCADE",
GameMode.Weekly => "WEEKLY",
_ => "CLASSIC"
};
// Weekly mode is a shared challenge that rolls over once a week. We count how many
// whole weeks have passed since this fixed start date, so everyone playing in the
// same week gets the same board and competes on the same leaderboard.
private static readonly DateTime WeekEpoch = new( 2024, 1, 1, 0, 0, 0, DateTimeKind.Utc );
/// <summary>
/// How many whole weeks have passed since WeekEpoch. Identifies the current weekly challenge.
/// </summary>
public static int WeeklyId => (int)((DateTime.UtcNow - WeekEpoch).TotalDays / 7.0);
/// <summary>
/// Which part of the game loop we're in (menu, serving, playing, game over).
/// </summary>
public GameState State { get; private set; } = GameState.Menu;
/// <summary>
/// The player's current score.
/// </summary>
public int Score { get; private set; }
/// <summary>
/// Balls remaining before it's game over.
/// </summary>
public int Lives { get; private set; }
/// <summary>
/// The level number within the current run (starts at 1).
/// </summary>
public int Level { get; private set; } = 1;
/// <summary>
/// True if the score just submitted beat the player's previous best.
/// </summary>
public bool IsNewHighScore { get; private set; }
/// <summary>
/// True once every brick has finished its drop-in animation and the board is ready to play.
/// </summary>
public bool BricksSpawnedIn => bricksSpawnedIn;
/// <summary>
/// How many bricks have been broken in a row without the ball touching the paddle.
/// </summary>
public int Combo => combo;
// These mirror the banner and damage-flash state from GameFeedback, because the HUD reads them
// from BreakoutGame. The "?." guards matter: the HUD can draw one frame before OnStart has
// resolved gameFeedback, so until then we hand back harmless defaults.
/// <summary>
/// Big center banner text, like "LEVEL 1" or "BOARD CLEAR!".
/// </summary>
public string BannerText => gameFeedback?.BannerText;
/// <summary>
/// Smaller line shown beneath the banner (may be null).
/// </summary>
public string BannerSub => gameFeedback?.BannerSub;
/// <summary>
/// Ticks up each time a new banner is shown, so the HUD can restart its animation.
/// </summary>
public int BannerSeq => gameFeedback?.BannerSeq ?? 0;
/// <summary>
/// True while the banner should currently be visible.
/// </summary>
public bool BannerActive => gameFeedback?.BannerActive ?? false;
/// <summary>
/// Ticks up each time the player takes damage, so the HUD can restart the red flash.
/// </summary>
public int DamageSeq => gameFeedback?.DamageSeq ?? 0;
/// <summary>
/// True while the red damage flash should currently be showing.
/// </summary>
public bool DamageActive => gameFeedback?.DamageActive ?? false;
private readonly List<Block> blocks = new();
private SkyController sky;
private bool advancePending; // waiting out the pause after a clear before loading the next level
private RealTimeSince sinceCleared; // how long since the board was cleared (drives that pause)
private TimeSince timeSinceSpawn; // how long since the current board's bricks were spawned
private bool bricksSpawnedIn = true; // false while bricks are still playing their drop-in animation
public LevelDirector levelDirector { get; private set; }
private BallManager ballManager;
private GameFeedback gameFeedback;
private bool lostLifeThisBoard; // used for the "flawless" (clear a board without dying) achievement
private bool lostLifeThisRun; // used for the "untouchable" (clear 3 boards, no deaths) achievement
private int expandsThisBoard; // wide-paddle pickups used this board (weekly caps how many count)
private int boardsClearedThisRun;
private int combo; // blocks broken in a row without the ball touching the paddle
private RealTimeSince sinceHitStop;
private float hitStopLength = -1f; // -1 means no hit-stop is currently running
private const int ComboMaxMultiplier = 50;
protected override void OnStart()
{
// Find the shared scene objects, and get (or create) the three sibling components.
// GetOrCreate means the game still runs even if someone forgot to add them in the editor.
Playfield ??= Scene.Get<Playfield>();
Spawner ??= Scene.Get<LevelSpawner>();
sky ??= Scene.Get<SkyController>();
levelDirector = Components.GetOrCreate<LevelDirector>();
ballManager = Components.GetOrCreate<BallManager>();
gameFeedback = Components.GetOrCreate<GameFeedback>();
// If no paddle was placed in the scene, spawn one from the prefab at the bottom center.
if ( !Paddle.IsValid() && PaddlePrefab is not null )
{
float cx = Playfield?.CenterX ?? 0f;
float py = Playfield?.PlayY ?? 0f;
var go = PaddlePrefab.Clone( new Vector3( cx, py, PaddleZ ) );
go.Enabled = true;
Paddle = go.Components.Get<Paddle>( FindMode.EverythingInSelfAndDescendants );
}
Paddle ??= Scene.Get<Paddle>();
Lives = StartingLives;
ShowMenu();
}
protected override void OnUpdate()
{
// Hide the cursor while the player is steering the paddle, show it in menus.
Mouse.Visibility = PaddleControlActive ? MouseVisibility.Hidden : MouseVisibility.Visible;
// Hit-stop is a brief slow-motion freeze on impact. When its timer runs out,
// put time back to normal.
if ( hitStopLength >= 0f && sinceHitStop >= hitStopLength )
{
Scene.TimeScale = 1f;
hitStopLength = -1f;
}
// After a board is cleared we wait BoardClearPause seconds (to enjoy the
// confetti) and then load the next level.
if ( advancePending && sinceCleared >= BoardClearPause )
{
advancePending = false;
Level++;
StartLevel();
}
// Bricks play a drop-in animation (BlockSpawnIn) when a level loads. The board
// counts as "ready" once none of those animations are still running.
bricksSpawnedIn = State != GameState.Serving || !Scene.GetAllComponents<BlockSpawnIn>().Any();
// If no breakable bricks are left, the board is clear. The timeSinceSpawn check
// skips the first half second so this can't fire before the bricks even exist.
if ( State == GameState.Playing && !advancePending && timeSinceSpawn > 0.5f
&& !blocks.Any( b => b.IsValid() && b.Breakable ) )
{
OnLevelCleared();
}
switch ( State )
{
case GameState.Serving:
UpdateServing();
break;
case GameState.GameOver:
if ( LaunchPressed() )
ShowMenu();
break;
}
// Freeze the world while the pause menu is open during play, otherwise run normally.
if ( (State == GameState.Serving || State == GameState.Playing) && IsPaused )
{
Scene.TimeScale = 0f;
}
else
{
Scene.TimeScale = 1f;
}
}
private static bool LaunchPressed() => Input.Pressed( "Attack1" ) || Input.Pressed( "Jump" );
// Returns to the main menu. The classic brick wall is respawned as a backdrop so the
// menu screen has something moving behind it.
private void ShowMenu()
{
ClearHitStop();
combo = 0;
ballManager.ClearBalls();
ClearPowerUps();
blocks.Clear();
Spawner?.SpawnClassic();
sky?.ToDay();
State = GameState.Menu;
}
/// <summary>
/// Starts a fresh run in the given mode. Resets score, lives and level, then loads the first level.
/// </summary>
public void StartGame( GameMode mode )
{
ClearHitStop();
Mode = mode;
Score = 0;
Lives = StartingLives;
Level = 1;
IsNewHighScore = false;
advancePending = false;
if ( Mode == GameMode.Arcade )
levelDirector.BeginArcadeRun();
ballManager.ResetClassicProgression();
lostLifeThisRun = false;
boardsClearedThisRun = 0;
if ( Paddle.IsValid() )
Paddle.ResetWidth();
if ( Mode == GameMode.Classic )
sky?.BeginClassicCycle();
StartLevel();
}
private void StartLevel()
{
lostLifeThisBoard = false;
expandsThisBoard = 0;
if ( Paddle.IsValid() )
Paddle.ClearExpandEffects();
ballManager.ClearBalls();
ClearPowerUps();
blocks.Clear();
if ( Mode == GameMode.Classic )
Spawner?.SpawnClassic();
else if ( Mode == GameMode.Weekly )
Spawner?.Spawn( levelDirector.CurrentLevel( Mode, Level ), levelDirector.WeeklySeed( Level ), WeeklyMaxExpandsPerLevel );
else
Spawner?.Spawn( levelDirector.CurrentLevel( Mode, Level ) );
if ( Mode != GameMode.Classic )
sky?.Randomize();
timeSinceSpawn = 0;
ServeBall();
gameFeedback.ShowBanner( $"LEVEL {Level}", ModeLabel, 1.0f );
}
/// <summary>
/// Registers a breakable block so the game can tell when the board has been cleared.
/// </summary>
public void RegisterBlock( Block block )
{
if ( !blocks.Contains( block ) )
blocks.Add( block );
}
/// <summary>
/// Called when the ball strikes a block, so ball-speed progression can respond.
/// </summary>
public void OnBlockHit( Ball ball, Block block ) => ballManager.OnBlockHit( ball, block );
/// <summary>
/// Called when the ball bounces off the top wall. In Classic mode this shrinks the paddle.
/// </summary>
public void OnBallReachedTop()
{
if ( Mode != GameMode.Classic || !Paddle.IsValid() || Paddle.IsShrunk )
return;
Paddle.Shrink( ClassicPaddleShrink );
}
/// <summary>
/// Called when a block is destroyed. Awards score (with the combo multiplier) and checks for a board clear.
/// </summary>
public void OnBlockDestroyed( Block block, int score )
{
blocks.Remove( block );
// In Arcade and Weekly, breaking blocks without the ball touching the paddle
// builds a combo that multiplies the score. Classic mode always scores at 1x so
// it matches the original game.
combo++;
int mult = Mode != GameMode.Classic ? Math.Clamp( combo, 1, ComboMaxMultiplier ) : 1;
int awarded = score * mult;
Score += awarded;
Stats.Increment( "bricks", 1 );
SpawnScorePopup( block, awarded, mult );
gameFeedback.SpawnDebris( block );
if ( timeSinceSpawn > 0.15f && !blocks.Any( b => b.IsValid() && b.Breakable ) )
OnLevelCleared();
}
private void OnLevelCleared()
{
HitStop( 0.10f, 0.04f );
gameFeedback.PlayClearSting();
gameFeedback.SpawnConfetti( new Vector3( 0, 0, PaddleZ ) );
if ( Mode == GameMode.Arcade )
levelDirector.AdvanceArcadeOrder();
ballManager.ResetClassicProgression();
if ( Paddle.IsValid() )
Paddle.ResetWidth();
Stats.Increment( "boards", 1 );
if ( !lostLifeThisBoard )
Achievements.Unlock( "flawless" );
boardsClearedThisRun++;
if ( !lostLifeThisRun && boardsClearedThisRun >= 3 )
Achievements.Unlock( "untouchable" );
ballManager.ClearBalls();
ClearPowerUps();
gameFeedback.ShowBanner( "BOARD CLEAR!", null, 1.15f );
advancePending = true;
sinceCleared = 0f;
}
private void SubmitScore() => IsNewHighScore = BreakoutLeaderboards.SubmitScore( Score, Mode );
/// <summary>
/// Shakes the camera by the given amount, for impact feel.
/// </summary>
public void ShakeCamera( float amount ) => gameFeedback?.ShakeCamera( amount );
/// <summary>
/// Briefly slows the whole scene to a crawl for a punchy "oomph" on big moments.
/// OnUpdate switches speed back to normal once the duration has passed.
/// </summary>
public void HitStop( float duration, float scale = 0.06f )
{
if ( duration <= 0f || State != GameState.Playing )
return;
Scene.TimeScale = scale;
sinceHitStop = 0f;
hitStopLength = duration;
}
private void ClearHitStop()
{
hitStopLength = -1f;
if ( Scene is not null )
Scene.TimeScale = 1f;
}
/// <summary>
/// Called when the ball hits the paddle. Resets the combo, since the streak only counts while the ball is airborne.
/// </summary>
public void OnPaddleBounce() => combo = 0;
/// <summary>
/// Asks whether a wide-paddle pickup may apply. Returns false once Weekly mode's per-board cap is reached.
/// </summary>
public bool TryApplyPaddleExpand()
{
expandsThisBoard++;
return Mode != GameMode.Weekly || expandsThisBoard <= WeeklyMaxExpandsPerLevel;
}
private void SpawnScorePopup( Block block, int awarded, int mult )
{
if ( awarded <= 0 || !block.IsValid() )
return;
string text = mult > 1 ? $"+{awarded} x{mult}" : $"+{awarded}";
var color = Color.Lerp( Color.White, new Color( 1f, 0.82f, 0.25f ),
(mult - 1) / (float)(ComboMaxMultiplier - 1) );
float scale = 0.22f + (mult - 1) * 0.03f;
if ( mult > 1 )
{
var snd = Sound.Play( "combo_01" );
if ( snd is not null )
snd.Pitch = 1 + mult * 0.1f;
}
MusicManager.DuckFor( 0.82f, 0.1f );
ScorePopup.Spawn( Scene, block.WorldPosition + Vector3.Up * 6f, text, color, scale );
}
/// <summary>
/// Spawns a spark effect where the ball hit a wall or the paddle.
/// </summary>
public void SpawnWallHit( Vector3 worldPos, Vector3 inwardNormal ) => gameFeedback?.SpawnWallHit( worldPos, inwardNormal );
private void ServeBall()
{
State = GameState.Serving;
combo = 0;
ballManager.SpawnServingBall();
}
private void UpdateServing()
{
ballManager.UpdateServingPosition();
if ( LaunchPressed() )
LaunchServingBall();
}
/// <summary>
/// Fires the stuck serving ball, moving from Serving to Playing (only if it actually launched).
/// </summary>
public void LaunchServingBall()
{
if ( State != GameState.Serving )
return;
if ( ballManager.LaunchServingBall() )
State = GameState.Playing;
}
/// <summary>
/// Returns to the menu from the game-over screen.
/// </summary>
public void ReturnToMenu()
{
if ( State == GameState.GameOver )
ShowMenu();
}
/// <summary>
/// Called when a ball drops off the bottom. BallManager removes that ball; if it was the
/// last one in play the player loses a life. Out of lives means game over, otherwise we
/// serve a fresh ball.
/// </summary>
public void OnBallLost( Ball ball )
{
ballManager.RemoveBall( ball );
if ( State != GameState.Playing || ballManager.HasBalls )
return;
Lives--;
lostLifeThisBoard = true;
lostLifeThisRun = true;
gameFeedback.TriggerDamageFeedback();
if ( Lives <= 0 )
{
State = GameState.GameOver;
Sound.Play( "game_over_01" );
MusicManager.DuckFor( 0.25f, 3 );
SubmitScore();
Achievements.Unlock( "warm_up" );
}
else
{
if ( Lives == 1 )
gameFeedback.ShowBanner( "LAST LIFE!", null, 1.1f );
ServeBall();
}
}
/// <summary>
/// Spawns extra balls that fan out from a ball already in play (the multi-ball pickup).
/// </summary>
public void SpawnExtraBalls( int count, float spreadAngle ) => ballManager.SpawnExtraBalls( null, count, spreadAngle );
/// <summary>
/// Spawns extra balls of a specific prefab (the extra-ball pickup).
/// </summary>
public void SpawnExtraBalls( GameObject prefab, int count, float spreadAngle ) => ballManager.SpawnExtraBalls( prefab, count, spreadAngle );
/// <summary>
/// Swaps a ball for a different ball-type prefab, keeping its position and heading.
/// </summary>
public void TransformBall( Ball oldBall, GameObject prefab ) => ballManager.TransformBall( oldBall, prefab );
private void ClearPowerUps()
{
foreach ( var powerUp in Scene.GetAllComponents<PowerUp>().ToList() )
{
if ( powerUp.IsValid() )
powerUp.GameObject.Destroy();
}
}
}