Flappy Bird clone

overview

This Unity 3D project reimagines the iconic Flappy Bird gameplay with a custom-built clone focused on simplicity, smooth mechanics, and modular design. Players control a character navigating between moving obstacles by tapping to maintain altitude—simple in concept, challenging in execution. Featuring collision-based game over logic, score tracking, and responsive input handling, the project serves as a clean, extensible template for experimenting with casual game mechanics, procedural generation, or UI integration.

Year

2025

Genre

Endless runner

Platform

PC / mobile

fly!
C#
				using System.Collections;
using System.Collections.Generic;
using UnityEditor.Tilemaps;
using UnityEngine;
using UnityEngine.InputSystem;

public class Player : MonoBehaviour
{
    [Header("Player Settings")]
    [SerializeField] private float flyForce = 1.5f;           // Force applied when player flies
    [SerializeField] private float rotationSpeed = 10f;       // Rotation multiplier based on vertical speed
    [SerializeField] private float autoFlyInterval = 0.5f;    // Time between auto-fly inputs

    private Rigidbody2D rb2D;

    private bool canMove = false;              // Controls if player can move (used after game start)
    private bool waitingForFirstInput = false; // True when waiting for first player input
    private bool firstInputReceived = false;   // True after first manual input is detected
    private bool autoFlying = false;           // True when auto-flying is active

    private Coroutine autoFlyRoutine;          // Reference to auto-fly coroutine
    private PlayerInput playerInput;           // PlayerInput system instance

    private void Awake()
    {
        playerInput = new PlayerInput();   // Create input system instance
        playerInput.Enable();              // Enable input system

        SetUpActions();                    // Bind input events
    }

    // Start auto-flying and wait for the player to tap for manual control
    public void StartAutoFly()
    {
        autoFlying = true;
        autoFlyRoutine = StartCoroutine(AutoFlyLoop());
        WaitForFirstInput();
    }

    // Stop auto-fly behavior if it's running
    public void StopAutoFly()
    {
        autoFlying = false;
        if (autoFlyRoutine != null)
            StopCoroutine(autoFlyRoutine);
    }

    // Repeatedly applies fly input at fixed intervals until first tap
    private IEnumerator AutoFlyLoop()
    {
        while (autoFlying)
        {
            Fly();
            yield return new WaitForSeconds(autoFlyInterval);
        }
    }

    // Set up player input actions
    private void SetUpActions()
    {
        // This handles input for fly action
        playerInput.Player.fly.performed += context =>
        {
            // If we’re still in auto-fly and waiting for first tap
            if (waitingForFirstInput && !firstInputReceived)
            {
                firstInputReceived = true;
                waitingForFirstInput = false;

                StopAutoFly();                          // Stop hovering
                GameManager.Instance.StartGamePlay();   // Start actual game
            }

            Fly(); // Apply fly movement
        };
    }

    // Handles flying logic (velocity upwards)
    private void Fly()
    {
        if (canMove)
        {
            rb2D.velocity = Vector2.up * flyForce;
        }
    }

    // Apply rotation based on vertical speed for visual feedback
    private void FixedUpdate()
    {
        if (canMove)
        {
            transform.rotation = Quaternion.Euler(0, 0, rb2D.velocity.y * rotationSpeed);
        }
    }

    // Unsubscribe input when object is disabled
    private void OnDisable()
    {
        playerInput.Player.fly.performed -= OnFly;
    }

    // Called to start waiting for the first manual tap
    public void WaitForFirstInput()
    {
        waitingForFirstInput = true;
        firstInputReceived = false;
    }

    // Triggered when player hits anything (walls, pipes, etc.)
    private void OnCollisionEnter2D(Collision2D collision)
    {
        GameManager.Instance.GameOver(); // End game
    }

    // Triggered when player enters score zone
    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.CompareTag("Score"))
        {
            GameManager.Instance.AddScore(); // Increase score
        }
    }

    // Enables or disables movement, and sets gravity accordingly
    public void SetMovement(bool value)
    {
        rb2D = gameObject.GetComponent<Rigidbody2D>();
        canMove = value;

        if (value)
        {
            rb2D.gravityScale = 0.56f; // Enable gravity
        }
        else
        {
            rb2D.gravityScale = 0f;    // Freeze player in place
        }
    }

    // Old unused input hook (cleanable)
    private void OnFly(InputAction.CallbackContext context)
    {
        Fly();
    }
}

			
C#
				using JetBrains.Annotations;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class GameManager : MonoBehaviour
{
    // Singleton instance so other scripts can access GameManager easily
    public static GameManager Instance { get; private set; }

    [Header("Player Settings")]
    [SerializeField] private Player playerScript;          // Reference to the player script
    [SerializeField] private GameObject player;            // Player GameObject
    [SerializeField] private Transform playerStartPos;     // Player start position and rotation

    [Header("Spawner")]
    [SerializeField] private PipeSpawner spawner;          // Pipe spawner reference

    [Header("Score")]
    [SerializeField] private int score;                    // Player's current score
    private bool canScore = false;                         // Whether scoring is currently allowed
    public int Score => score;                             // Public read-only property for score

    private void Awake()
    {
        // Set up the singleton pattern
        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            Instance = this;
        }

        SetUpPlayer();                        // Reset player to starting state
        UIManager.Instance.ShowMainMenu();   // Show main menu at game launch
    }

    // Called from UI to start the game
    public void PlayGame()
    {
        UIManager.Instance.HideAllUI();           // Hide menus and overlays
        StartCoroutine(StartIntroSequence());     // Start flying intro
    }

    // Short intro before manual control
    private IEnumerator StartIntroSequence()
    {
        playerScript.SetMovement(true);     // Enable gravity/movement
        yield return new WaitForSeconds(0.2f);
        playerScript.StartAutoFly();        // Begin hovering automatically
    }

    // Reset all game elements and go back to main menu
    public void ResetGame()
    {
        SetUpPlayer();                      // Reposition player
        spawner.ClearSpawner();            // Remove all pipes
        UIManager.Instance.ShowMainMenu(); // Show main menu UI
    }

    // Called when player dies or hits obstacle
    public void GameOver()
    {
        canScore = false;                          // Disable scoring
        playerScript.SetMovement(false);           // Freeze player
        spawner.StopSpawning();                    // Stop pipe spawning
        UIManager.Instance.ShowGameOverScreen();   // Show "Game Over"
        UIManager.Instance.ShowScoreGameOver();    // Display final score
    }

    // Called when player taps after intro to start real gameplay
    public void StartGamePlay()
    {
        spawner.StartSpawning();   // Start spawning pipes
        canScore = true;           // Allow scoring
    }

    // Adds 1 to current score (called by Player when scoring trigger is hit)
    public void AddScore()
    {
        score += 1;
    }

    // Returns current score
    public int GetCurrentScore() { return score; }

    // Returns whether the player is allowed to score
    public bool GetCanScore() { return canScore; }

    // Resets player position and disables movement
    private void SetUpPlayer()
    {
        playerScript.SetMovement(false);
        player.transform.SetPositionAndRotation(playerStartPos.position, playerStartPos.rotation);
    }

    // Public getter for player start position (if needed by other scripts)
    public Transform PlayerStartPos() => playerStartPos;
}

			
C#
				using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Pipe : MonoBehaviour
{
    [Header("Speed Settings")]
    [SerializeField] private float moveSpeed = 0.65f;   // How fast the pipe moves to the left
    private bool canMove = true;                        // Controls if the pipe should move

    private float lifeTime = 10f;   // Max time a pipe can exist before auto-destroy
    private float timer = 0f;       // Timer to track how long the pipe has been alive

    private PipeSpawner spawner;    // Reference to the spawner that created this pipe

    // Called when pipe is spawned, gives it a reference back to the spawner
    public void Init(PipeSpawner spawnerRef)
    {
        spawner = spawnerRef;
    }

    private void Update()
    {
        if (canMove)
        {
            // Move the pipe left based on speed and deltaTime
            transform.position += Vector3.left * moveSpeed * Time.deltaTime;

            timer += Time.deltaTime;

            // Destroy pipe if lifetime is up or it has moved off-screen
            if (timer >= lifeTime || IsOffScreen())
            {
                spawner.RemovePipe(this.gameObject);  // Inform spawner to remove this pipe
                Destroy(gameObject);                  // Destroy the pipe
            }
        }
    }

    // Returns true if the pipe is far off the left side of the screen
    private bool IsOffScreen()
    {
        Vector3 screenPos = Camera.main.WorldToViewportPoint(transform.position);
        return screenPos.x < -2; // -2 ensures it’s really far off-screen before removal
    }

    // Called externally to stop the pipe from moving (e.g. on game over)
    public void StopMovement()
    {
        canMove = false;
    }
}

			
C#
				using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PipeMovement : MonoBehaviour
{
    [Header("Pipe Movement")]
    [SerializeField] private float pipeMovement = 5f;   // Speed at which the pipe moves left
    private bool canMove = true;                        // Controls whether the pipe is currently moving

    private void Update()
    {
        // Only move the pipe if movement is enabled
        if (canMove)
        {
            transform.position += Vector3.left * pipeMovement * Time.deltaTime;
        }
    }

    // Called externally (e.g. on game over) to stop the pipe from moving
    public void StopMovement()
    {
        canMove = false;
    }
}

			
C#
				using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PipeSpawner : MonoBehaviour
{
    [SerializeField] private float maxTime = 1.5f;          // Time between each pipe spawn
    [SerializeField] private float heightRange = 0.45f;     // Random Y offset for pipe spawn position
    [SerializeField] private GameObject pipePrefab;         // Pipe prefab to spawn
    [SerializeField] private List<GameObject> activePipes;  // List of currently active pipes in the scene

    private float timer;             // Timer to track spawn intervals
    private bool canSpawn = false;   // Controls whether spawning is active

    private void Update()
    {
        // If it's time to spawn a pipe and spawning is allowed
        if (timer > maxTime && canSpawn)
        {
            SpawnPipe();
            timer = 0;
        }

        timer += Time.deltaTime;
    }

    // Spawns a new pipe at a random vertical position
    private void SpawnPipe()
    {
        Vector3 spawnPos = transform.position + new Vector3(0, Random.Range(-heightRange, heightRange));
        GameObject pipe = Instantiate(pipePrefab, spawnPos, Quaternion.identity);

        pipe.GetComponent<Pipe>().Init(this);  // Give the pipe a reference back to this spawner
        activePipes.Add(pipe);                // Add to the active list
    }

    // Called by a pipe when it's destroyed to remove it from the list
    public void RemovePipe(GameObject pipe)
    {
        if (activePipes.Contains(pipe))
        {
            activePipes.Remove(pipe);
        }
    }

    // Starts spawning pipes
    public void StartSpawning()
    {
        canSpawn = true;
    }

    // Stops spawning and halts movement on all existing pipes
    public void StopSpawning()
    {
        canSpawn = false;

        foreach (GameObject pipe in activePipes)
        {
            Pipe p = pipe.GetComponent<Pipe>();
            p.StopMovement(); // Stop pipe movement (e.g. on game over)
        }
    }

    // Clears all active pipes and resets the list
    public void ClearSpawner()
    {
        foreach (GameObject activePipe in activePipes)
        {
            Destroy(activePipe); // Destroy all pipe GameObjects
        }

        activePipes.Clear(); // Empty the list
    }
}

			

Used assets