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

Features

  • Tap-to-place stacking blocks
  • Moving platform to tap-stop
  • Overhang trimming on placement
  • Stack height tracking
  • Perfect placement bonus detection

When to Use This

Purpose-built for hyper-casual stacking games like Stack or similar tap-timing games. Use this for any mobile game mechanic where players time a tap to align a moving object with the previous placement — also works as a minigame within larger projects.

Common Mistakes

The perfectThreshold is in world units, not screen pixels — a value of 0.05 may be too tight on devices with high DPI, making perfect placements frustrating. The blockPrefab must have a Renderer component or the perfect placement color change won't work. Falling pieces are created as primitives with Rigidbody, so ensure your scene has proper physics gravity settings or they'll fall at unexpected speeds.

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.