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!
C#
				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);
        }
    }
}

			
C#
				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];
    }


}

			
C#
				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);
    }
}

			
C#
				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;
    }

}

			
C#
				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