Part of these game systems:
beginner Touch & Mobile

Stack Mechanic

Tap-to-place stacking game mechanic where a moving block must be stopped to align with the previous block. Features overhang trimming, perfect placement detection, and stack height tracking.

Unity 2022.3+ · 4.1 KB · StackMechanic.cs

How to Use

1

Create an empty GameObject and attach the StackMechanic script

2

Create a simple cube prefab and assign it to the blockPrefab field

3

Adjust blockHeight, moveSpeed, and moveRange in the inspector

4

Set the perfectThreshold for how close counts as a perfect placement

5

Wire up onBlockPlaced, onPerfectPlacement, and onGameOver events for UI and effects

Source Code

StackMechanic.cs
C#
using UnityEngine;
using UnityEngine.Events;

public class StackMechanic : MonoBehaviour
{
    [Header("Block Settings")]
    [SerializeField] private GameObject blockPrefab;
    [SerializeField] private float blockHeight = 0.2f;
    [SerializeField] private float moveSpeed = 3f;
    [SerializeField] private float moveRange = 3f;

    [Header("Placement")]
    [SerializeField] private float perfectThreshold = 0.05f;
    [SerializeField] private Color perfectColor = Color.yellow;

    [Header("Events")]
    public UnityEvent onBlockPlaced;
    public UnityEvent onPerfectPlacement;
    public UnityEvent onGameOver;
    public UnityEvent<int> onStackHeightChanged;

    public int StackHeight { get; private set; }
    public int PerfectStreak { get; private set; }
    public bool IsGameOver { get; private set; }

    private GameObject currentBlock;
    private GameObject previousBlock;
    private float previousBlockSizeX;
    private float previousBlockPosX;
    private bool movingRight = true;
    private bool isPlaying;

    private void Start()
    {
        StartGame();
    }

    public void StartGame()
    {
        // Clear existing blocks
        foreach (Transform child in transform)
            Destroy(child.gameObject);

        StackHeight = 0;
        PerfectStreak = 0;
        IsGameOver = false;
        isPlaying = true;

        // Create base block
        previousBlock = CreateBlock(Vector3.zero, 2f);
        previousBlockSizeX = 2f;
        previousBlockPosX = 0f;

        SpawnNextBlock();
    }

    private void Update()
    {
        if (!isPlaying || IsGameOver || currentBlock == null) return;

        // Touch or click to place
        if (Input.GetMouseButtonDown(0) || (Input.touchCount > 0 && Input.GetTouch(0).phase == TouchPhase.Began))
        {
            PlaceBlock();
            return;
        }

        // Move current block
        Vector3 pos = currentBlock.transform.localPosition;

        if (movingRight)
            pos.x += moveSpeed * Time.deltaTime;
        else
            pos.x -= moveSpeed * Time.deltaTime;

        if (pos.x >= moveRange) movingRight = false;
        if (pos.x <= -moveRange) movingRight = true;

        currentBlock.transform.localPosition = pos;
    }

    private void SpawnNextBlock()
    {
        float yPos = (StackHeight + 1) * blockHeight;
        Vector3 spawnPos = new Vector3(-moveRange, yPos, 0f);

        currentBlock = CreateBlock(spawnPos, previousBlockSizeX);
        movingRight = true;
    }

    private GameObject CreateBlock(Vector3 position, float sizeX)
    {
        GameObject block;

        if (blockPrefab != null)
        {
            block = Instantiate(blockPrefab, transform);
        }
        else
        {
            block = GameObject.CreatePrimitive(PrimitiveType.Cube);
            block.transform.SetParent(transform);
        }

        block.transform.localPosition = position;
        block.transform.localScale = new Vector3(sizeX, blockHeight, 1f);

        return block;
    }

    private void PlaceBlock()
    {
        float currentPosX = currentBlock.transform.localPosition.x;
        float currentSizeX = currentBlock.transform.localScale.x;

        float overhang = currentPosX - previousBlockPosX;
        float absOverhang = Mathf.Abs(overhang);

        // Check if completely missed
        if (absOverhang >= currentSizeX)
        {
            GameOver();
            return;
        }

        // Perfect placement check
        if (absOverhang <= perfectThreshold)
        {
            PerfectStreak++;
            currentBlock.transform.localPosition = new Vector3(
                previousBlockPosX,
                currentBlock.transform.localPosition.y,
                0f);

            Renderer rend = currentBlock.GetComponent<Renderer>();
            if (rend != null)
                rend.material.color = perfectColor;

            onPerfectPlacement?.Invoke();
        }
        else
        {
            PerfectStreak = 0;

            // Trim the block
            float newSizeX = currentSizeX - absOverhang;
            float newPosX = previousBlockPosX + overhang * 0.5f;

            currentBlock.transform.localScale = new Vector3(
                newSizeX, blockHeight, 1f);
            currentBlock.transform.localPosition = new Vector3(
                newPosX,
                currentBlock.transform.localPosition.y,
                0f);

            // Spawn falling piece
            SpawnFallingPiece(currentPosX, currentBlock.transform.localPosition.y,
                absOverhang, overhang > 0);

            previousBlockSizeX = newSizeX;
            previousBlockPosX = newPosX;
        }

        if (absOverhang <= perfectThreshold)
        {
            previousBlockSizeX = currentBlock.transform.localScale.x;
            previousBlockPosX = currentBlock.transform.localPosition.x;
        }

        StackHeight++;
        previousBlock = currentBlock;
        currentBlock = null;

        onBlockPlaced?.Invoke();
        onStackHeightChanged?.Invoke(StackHeight);

        SpawnNextBlock();
    }

    private void SpawnFallingPiece(float blockPosX, float yPos, float pieceSize, bool rightSide)
    {
        GameObject piece = GameObject.CreatePrimitive(PrimitiveType.Cube);
        piece.transform.SetParent(transform);

        float piecePosX = rightSide
            ? blockPosX + (previousBlockSizeX - pieceSize) * 0.5f + pieceSize * 0.5f
            : blockPosX - (previousBlockSizeX - pieceSize) * 0.5f - pieceSize * 0.5f;

        piece.transform.localPosition = new Vector3(piecePosX, yPos, 0f);
        piece.transform.localScale = new Vector3(pieceSize, blockHeight, 1f);

        Rigidbody rb = piece.AddComponent<Rigidbody>();
        rb.mass = 0.5f;

        Destroy(piece, 3f);
    }

    private void GameOver()
    {
        IsGameOver = true;
        isPlaying = false;

        Rigidbody rb = currentBlock.AddComponent<Rigidbody>();
        rb.mass = 0.5f;

        onGameOver?.Invoke();
    }
}
Ready for more? Score Manager Complete score management system with high score persistence, combo multipliers with decay, and score change events.