Not Arkanoid!
overview
This project started as a simple challenge: “How polished can a small arcade game become?” The result is my own modernized Arkanoid clone, built with responsive controls, satisfying physics, and a modular system that makes adding new power‑ups or brick types effortless.
I focused heavily on feel: crisp collisions, punchy effects, smooth animations, and a feedback loop that keeps the player in flow. Every element — from the paddle movement to the particle trails — was tuned to make the game feel alive.
It’s a compact project, but one that showcases my approach to game development: clean architecture, strong iteration, and a commitment to making even simple mechanics feel great.
Year
2026
Genre
Arcade brick braker
Platform
PC
Shoot!
using MoreMountains.Feedbacks;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerMovement : MonoBehaviour
{
[SerializeField] private float speed;
[SerializeField] private Transform ballStartPos;
[SerializeField] private float rotationSpeed = 90f; // degrees per second
[SerializeField] private float minAngle = -90f;
[SerializeField] private float maxAngle = 90f;
[SerializeField] private Vector2 launchDirection = new Vector2(1, 4);
[SerializeField] private Transform launchArrow;
[Header("Feedbacks")]
[SerializeField] private MMF_Player extendFeedback;
[SerializeField] private MMF_Player backToNormalFeedback;
[SerializeField] private MMF_Player extendEndingFeedback;
[SerializeField] private MMF_Player enableMagnetFeedback;
[SerializeField] private MMF_Player disableMagnetFeedback;
[SerializeField] private float extendDuration = 5f;
[SerializeField] private float magnetDuration = 5f;
private float currentAngle = 0f;
private bool rotatingforward = true;
private bool launch = false;
private PlayerInputActions inputActions;
private BoxCollider2D boxCol2D;
private int childCount;
// Powerups
private bool isExtended = false;
private bool endingFeedbackPlayed = false;
private float extendTimer = 0f;
private float maxExtendTime = 30f;
private bool magnetEnabled = false;
private float magnetTimer = 0f;
private float maxMagnetTime = 30f;
private Coroutine magnetRoutine;
private void Awake()
{
inputActions = new PlayerInputActions(); // create instance
inputActions.Player.Lauch.performed += ctx => Launch();
boxCol2D = GetComponent<BoxCollider2D>();
childCount = transform.childCount;
}
public void SetupBall(GameObject ball)
{
GameObject currentBall = Instantiate(ball, ballStartPos.position, Quaternion.identity, transform);
}
private void Update()
{
// Debug
if (Input.GetKeyDown(KeyCode.P))
{
EnableMagnet();
}
Vector2 moveInput = inputActions.Player.Move.ReadValue<Vector2>();
Vector3 move = new Vector3(moveInput.x, 0, 0);
transform.Translate(move * speed * Time.deltaTime);
if(transform.childCount > childCount) // Ball attached
{
launchArrow.gameObject.SetActive(true);
// Move angle
if (rotatingforward)
currentAngle += rotationSpeed * Time.deltaTime;
else
currentAngle -= rotationSpeed * Time.deltaTime;
// Clamp & reverse direction
if (currentAngle >= maxAngle)
rotatingforward = false;
else if (currentAngle <= minAngle)
rotatingforward = true;
// Convert angel to direction vector
float rad = (currentAngle + 90f) * Mathf.Deg2Rad;
launchDirection = new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)).normalized;
// Rotate arrow visually
if (launchArrow != null)
launchArrow.localRotation = Quaternion.Euler(0, 0, currentAngle);
}
else
{
launchArrow.gameObject.SetActive(false);
}
}
private void Launch()
{
// only launch if there are childs attached
if(transform.childCount > childCount)
{
// Collect all attached balls
List<Ball> ballsToLaunch = new List<Ball>();
foreach (Transform child in transform)
{
Ball ball = child.GetComponent<Ball>();
if (ball != null)
ballsToLaunch.Add(ball);
}
// Launch each ball with a slight angle offset
float angleStep = 10f;
float startAngle = -(angleStep * (ballsToLaunch.Count - 1) / 2f);
for (int i = 0; i < ballsToLaunch.Count; i++)
{
Ball ball = ballsToLaunch[i];
// Calculate unique angle
float angleOffset = startAngle + (i * angleStep);
Vector2 launchDir = Quaternion.Euler(0, 0, angleOffset) * launchDirection;
// Detach from paddle
ball.transform.SetParent(null);
// Launch
ball.Launch(launchDir.normalized);
}
}
}
private void OnEnable()
{
inputActions.Enable();
}
private void OnDisable()
{
inputActions.Disable();
}
// Upgrades
public void Extend()
{
extendTimer = Mathf.Min(extendTimer + extendDuration, maxExtendTime); // Prevent infinate Stacking
if (!isExtended)
StartCoroutine(ExtendCO());
}
private IEnumerator ExtendCO()
{
isExtended = true;
endingFeedbackPlayed = false;
extendFeedback.PlayFeedbacks();
Vector2 startSize = boxCol2D.size;
Vector2 targetSize = new Vector2(5f, startSize.y);
float t = 0f;
float speed = 5f;
// Smoothly grows collider
while (t < 1f)
{
t += Time.deltaTime * speed;
boxCol2D.size = Vector2.Lerp(startSize, targetSize, t);
yield return null;
}
// Stay extended while timer > 0
while(extendTimer > 0f)
{
extendTimer -= Time.deltaTime;
if (!endingFeedbackPlayed && extendTimer < 1f)
{
extendEndingFeedback.PlayFeedbacks();
endingFeedbackPlayed = true;
}
yield return null;
}
// Smoothly shrinks collider
t = 0f;
while (t < 1f)
{
t += Time.deltaTime * speed;
boxCol2D.size = Vector2.Lerp(targetSize, startSize, t);
yield return null;
}
backToNormalFeedback.PlayFeedbacks();
isExtended = false;
endingFeedbackPlayed = false;
}
public void EnableMagnet()
{
Debug.Log("Paddle Magnet");
// Add time to the magnet timer
magnetTimer = Mathf.Min(magnetTimer + magnetDuration, maxMagnetTime);
// Start coroutine if not already running
if (!magnetEnabled)
magnetRoutine = StartCoroutine(MagnetCO());
}
private IEnumerator MagnetCO()
{
magnetEnabled = true;
// Optional: FEEL feedback for activation
// magnetStartFeedback.PlayFeedbacks();
enableMagnetFeedback.PlayFeedbacks();
while (magnetTimer > 0f)
{
magnetTimer -= Time.deltaTime;
yield return null;
}
// Optional: FEEL feedback for ending
// magnetEndFeedback.PlayFeedbacks();
disableMagnetFeedback.PlayFeedbacks();
magnetEnabled = false;
magnetRoutine = null;
Launch();
}
public void ResetPowreUps()
{
// Reset extend
isExtended = false;
extendTimer = 0f;
endingFeedbackPlayed = false;
// Reset collider size
boxCol2D.size = new Vector2(2f, boxCol2D.size.y); // Default size
backToNormalFeedback.PlayFeedbacks();
// Reset magnet
magnetEnabled = false;
disableMagnetFeedback.PlayFeedbacks();
}
private void OnCollisionEnter2D(Collision2D collision)
{
if(magnetEnabled && collision.gameObject.CompareTag("Ball"))
{
Ball ball = collision.gameObject.GetComponent<Ball>();
ball.Catch(transform);
}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Manages the bricks
/// </summary>
public class BrickManager : MonoBehaviour
{
public static BrickManager Instance { get; private set; }
[Header("Level Settings")]
[SerializeField] private int rows = 5;
[SerializeField] private int columns = 10;
[SerializeField] private float spacingX = 0.2f;
[SerializeField] private float spacingY = 0.2f;
[Header("Brick types")]
[SerializeField] private List<BrickSO> brickSOs = new();
[Header("Level parent")]
[SerializeField] private Transform brickParent;
[SerializeField] private int activeBricks = 0;
private IBrickLayoutProvider layoutProvider;
private IBrickFactory brickFactory;
private LevelPattern currentLevelPattern;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(this);
}
else
{
Instance = this;
}
layoutProvider = new GridLayoutProvider(rows, columns, spacingX, spacingY);
brickFactory = new BrickFactory(brickParent);
}
private void Start()
{
GenerateLevel();
}
private void GenerateLevel()
{
CenterParentToCamera();
if (brickSOs == null || brickSOs.Count == 0)
{
Debug.LogWarning("No bricks assigned!");
return;
}
// use brick to calculate size (assuming all bricks have the same size
float brickW = GetBrickWidth(brickSOs[0]);
float brickH = GetBrickHeight(brickSOs[0]);
float levelWidth = (columns * brickW) + ((columns - 1) * spacingX);
float levelHeight = (rows * brickH) + ((rows - 1) * spacingY);
Vector2 topLeft = new Vector2(-levelWidth / 2f + brickW / 2f, levelHeight / 2f - brickH / 2f);
if (layoutProvider is GridLayoutProvider grid)
{
grid.SetTopLeft(topLeft, brickW, brickH);
switch(currentLevelPattern)
{
case LevelPattern.Default:
grid.patternFunc = DefaultPattern;
break;
case LevelPattern.CheckerboardPattern:
grid.patternFunc = CheckerboardPattern;
break;
case LevelPattern.SkipPattern:
grid.patternFunc = SkipPattern;
break;
case LevelPattern.PyramidPattern:
grid.patternFunc = PyramidPattern;
break;
case LevelPattern.RandomPattern:
grid.patternFunc = RandomPattern;
break;
}
}
List<BrickSpawnData> bricksToSpawn = layoutProvider.GenerateLayout(brickSOs);
foreach (var brick in bricksToSpawn)
{
brickFactory.SpawnBrick(brick.brickSO, brick.position);
}
}
public void RegisterBrick()
{
activeBricks++;
}
public void UnregisterBrick()
{
activeBricks--;
if (activeBricks <= 0)
GameManager.Instance.LevelComplete();
}
public void ClearLevel()
{
foreach (Transform child in brickParent)
Destroy(child.gameObject);
activeBricks = 0;
}
public void LoadLevel(LevelSO definition)
{
currentLevelPattern = definition.levelPattern;
GenerateLevel();
}
private void CenterParentToCamera()
{
Vector3 camPos = Camera.main.transform.position;
brickParent.position = new Vector3(camPos.x, camPos.y, brickParent.position.z);
}
private float GetBrickWidth(BrickSO brick)
{
var sr = brick.brickPrefab.GetComponentInChildren<SpriteRenderer>();
return sr.bounds.size.x;
}
private float GetBrickHeight(BrickSO brick)
{
var sr = brick.brickPrefab.GetComponentInChildren<SpriteRenderer>();
return sr.bounds.size.y;
}
// Patterns
private BrickSO CheckerboardPattern(int row, int col, BrickSO defaultType)
{
return (row + col) % 2 == 0 ? defaultType : brickSOs[1];
}
private BrickSO SkipPattern(int row, int col, BrickSO defaultType)
{
return col % 2 == 0 ? defaultType : null;
}
private BrickSO PyramidPattern(int row, int col, BrickSO defaultType)
{
int start = row;
int end = columns - row - 1;
if (col < start || col > end)
return null;
return defaultType;
}
private BrickSO RandomPattern(int row, int col, BrickSO defaultType)
{
return brickSOs[Random.Range(0, brickSOs.Count)];
}
private BrickSO DefaultPattern(int row, int col, BrickSO defaultType)
{
return brickSOs[row % brickSOs.Count];
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Ball : MonoBehaviour
{
[SerializeField] private float speed = 15f;
private Rigidbody2D rb2d;
private void Awake()
{
rb2d = GetComponent<Rigidbody2D>();
}
private void Start()
{
BallManager.Instance.RegisterBall(this);
}
public void Launch(Vector2 direciton)
{
transform.parent = null;
rb2d.simulated = true;
rb2d.velocity = direciton.normalized * speed;
}
public void Catch(Transform parent)
{
transform.parent = parent;
rb2d.simulated = false;
rb2d.velocity = Vector2.zero;
}
public void SplitBall()
{
Vector2 baseDir = rb2d.velocity.normalized;
if (baseDir == Vector2.zero)
baseDir = Vector2.up;
Vector2 dir1 = Quaternion.Euler(0, 0, 20f) * baseDir;
Vector2 dir2 = Quaternion.Euler(0, 0, -20f) * baseDir;
GameManager.Instance.SpawnSplitBall(transform.position, dir1);
GameManager.Instance.SpawnSplitBall(transform.position, dir2);
}
public void DestroyBall()
{
BallManager.Instance.UnregisterBall(this);
Destroy(gameObject);
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static UnityEditor.ShaderGraph.Internal.Texture2DShaderProperty;
/// <summary>
/// Stratagy pattern
/// This decides 'where' the bricks go
/// </summary>
public interface IBrickLayoutProvider
{
List<BrickSpawnData> GenerateLayout(List<BrickSO> brickTypes);
}
public class GridLayoutProvider : IBrickLayoutProvider
{
private int rows;
private int columns;
private float spacingX;
private float spacingY;
private Vector2 topLeft;
private float brickW;
private float brickH;
public System.Func<int, int, BrickSO, BrickSO> patternFunc; // Returns the brick type to use (or null to skip)
public GridLayoutProvider(int rows, int columns, float spacingX, float spacingY)
{
this.rows = rows;
this.columns = columns;
this.spacingX = spacingX;
this.spacingY = spacingY;
}
public void SetTopLeft(Vector2 topLeft, float brickW, float brickH)
{
this.topLeft = topLeft;
this.brickW = brickW;
this.brickH = brickH;
}
public List<BrickSpawnData> GenerateLayout(List<BrickSO> brickTypes)
{
List<BrickSpawnData> result = new();
for (int row = 0; row < rows; row++)
{
BrickSO defaultType = brickTypes[row % brickTypes.Count];
for (int col = 0; col < columns; col++)
{
BrickSO finalType = patternFunc != null
? patternFunc(row, col, defaultType)
: defaultType;
if (finalType == null)
continue; // skip brick
Vector2 pos = new Vector2(
topLeft.x + col * (brickW + spacingX),
topLeft.y - row * (brickH + spacingY)
);
result.Add(new BrickSpawnData(finalType, pos));
//Vector2 pos = new Vector2(topLeft.x + col * (brickW + spacingX), topLeft.y - row * (brickH + spacingY));
//result.Add(new BrickSpawnData(brickType, pos));
}
}
return result;
}
}
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameManager : MonoBehaviour
{
// Events
public event Action<int> OnLivesChanged;
public event Action<int> OnScoreChanged;
public static GameManager Instance { get; private set; }
[Header("Player position & movemnet settings")]
[SerializeField] private GameObject playerPaddlePrefab;
[SerializeField] private Transform playerStartPos;
[SerializeField] private GameObject ballPrefab;
[SerializeField] private List<LevelSO> levelDefinitions = new List<LevelSO>();
private PlayerMovement playerPaddle;
public bool IsTransitioning { get; private set; }
// amount of lives until game over
private int currentLives = 3;
private int maxLives = 5;
private int currentScore = 0;
private int currentLevel = 0;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(this);
}
else
{
Instance = this;
}
SetupGame();
}
public void AddScore(int amount)
{
currentScore += amount;
OnScoreChanged?.Invoke(currentScore);
}
public void LoseLife(Transform pos)
{
SoundManager.Instance.PlaySFX("Boom");
ParticleManager.Instance.PlayEffect("DeathVFX", pos.position);
currentLives--;
OnLivesChanged?.Invoke(currentLives);
if (currentLives <= 0)
{
GameOver();
return;
}
SetupGame();
}
public void AddLife()
{
currentLives++;
if (currentLives > maxLives)
{
currentLives = maxLives;
}
OnLivesChanged?.Invoke(currentLives);
}
private void SetupGame()
{
if (playerPaddle == null)
{
GameObject playerPaddleGO = Instantiate(playerPaddlePrefab, playerStartPos.position, Quaternion.identity);
playerPaddle = playerPaddleGO.GetComponent<PlayerMovement>();
playerPaddle.SetupBall(ballPrefab);
PowerUpManager.Instance.SetPlayerPaddle(playerPaddle);
}
else
{
playerPaddle.transform.position = playerStartPos.position;
playerPaddle.SetupBall(ballPrefab);
// Destroy falling powerups
// needs to be cleaner
foreach (var pu in GameObject.FindGameObjectsWithTag("PowerUp"))
{
Destroy(pu);
}
// lerp back to start position
}
}
public void SpawnSplitBall(Vector3 position, Vector2 direction)
{
GameObject newBallObj = Instantiate(ballPrefab, position, Quaternion.identity);
Ball newBall = newBallObj.GetComponent<Ball>();
newBall.Launch(direction.normalized);
}
public void LevelComplete()
{
currentLevel++;
Debug.Log("Level complets");
//Load next level(or loop, or end game)
if (currentLevel >= levelDefinitions.Count)
{
GameWon();
}
else
{
//LoadLevel(currentLevel);
StartCoroutine(LevelTransitionCO(currentLevel));
}
}
private IEnumerator LevelTransitionCO(int nextLevelIndex)
{
IsTransitioning = true;
// Disable Paddle movement
playerPaddle.enabled = false;
// Destroy falling powerups
foreach (var pu in GameObject.FindGameObjectsWithTag("PowerUp"))
{
Destroy(pu);
}
// Destroy all balls
foreach (var ball in FindObjectsOfType<Ball>())
{
BallManager.Instance.UnregisterBall(ball);
Destroy(ball.gameObject);
}
playerPaddle.ResetPowreUps();
// Lerp paddle to center
Vector3 startPos = playerPaddle.transform.position;
Vector3 targetPos = playerStartPos.position;
float t = 0f;
float speed = 2f;
while(t < 1f)
{
t += Time.deltaTime * speed;
playerPaddle.transform.position = Vector3.Lerp(startPos, targetPos, t);
yield return null;
}
// Load next level
BrickManager.Instance.ClearLevel();
BrickManager.Instance.LoadLevel(levelDefinitions[nextLevelIndex]);
// Spawn new ball
playerPaddle.SetupBall(ballPrefab);
// Re-enable paddle
playerPaddle.enabled = true;
IsTransitioning = false;
}
public void LoadLevel(int index)
{
BrickManager.Instance.ClearLevel();
BrickManager.Instance.LoadLevel(levelDefinitions[index]);
playerPaddle.SetupBall(ballPrefab);
}
public void GameWon()
{
Debug.Log("Game Won");
}
public void GameOver()
{
Debug.Log("Game Over");
}
public int GetCurrentLives() => currentLives;
public int GetCurrentScore() => currentScore;
}
Used assets
- Asset pack: Violet Themed UI
Publisher: The GUI Guy
