Endless fun

overview

A vibrant and engaging endless runner built in Unity 3D using the SYNTY Kids asset pack. Players take control of a small, adventurous dinosaur, dodging obstacles and collecting rewards in a cheerful low-poly world. Designed with dynamic gameplay, intuitive controls, and randomized challenges to keep the experience fresh and fun.

Developed using a rapid prototyping approach to quickly iterate on core features, validate ideas through feedback, and deliver a functional proof-of-concept efficiently.

Year

2025

Genre

Endless runner

Platform

PC

Take a look!
Code Highlights
C#
				using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [Header("Player Settings")]
    [SerializeField] private GameObject playerVisual;

    [Header("Jump Settings")]
    [SerializeField] private float jumpForce = 5f;           // Force applied when jumping
    [SerializeField] private LayerMask groundLayer;          // Ground layer for raycast

    [Header("Gravity Modifiers")]
    [SerializeField] private float fallMultiplier = 2.5f;    // Gravity multiplier when falling

    private PlayerInputActions inputActions;
    private Rigidbody rb;
    private bool jumpPressed = false;

    private PlayerDamageable damageable;
    private BounceWalk playerBounceWalk;

    // Setup references and input bindings
    private void Awake()
    {
        rb = GetComponent<Rigidbody>();
        inputActions = new PlayerInputActions();
        inputActions.Enable();

        // Bind jump action
        inputActions.Player.Jump.performed += Jump_performed;

        // Subscribe to death event
        damageable = GetComponent<PlayerDamageable>();
        damageable.OnDeath += HandleDeath;

        // Reference to bounce walking visuals (if any)
        playerBounceWalk = GetComponent<BounceWalk>();
    }

    // Called on a physics update
    private void FixedUpdate()
    {
        // Apply jump if pressed and grounded
        if (jumpPressed && IsGrounded())
        {
            Vector3 v = rb.velocity;
            v.y = jumpForce;
            rb.velocity = v;
        }

        // Apply extra gravity when falling for snappier jumps
        if (rb.velocity.y < 0f)
        {
            Vector3 v = rb.velocity;
            v.y += Physics.gravity.y * (fallMultiplier - 1f) * Time.deltaTime;
            rb.velocity = v;
        }

        // Reset jump flag after applying it
        jumpPressed = false;
    }

    // Check if player is on the ground using a downward raycast
    private bool IsGrounded()
    {
        return Physics.Raycast(transform.position, Vector3.down, 0.6f, groundLayer);
    }

    // Called when the jump input is triggered
    private void Jump_performed(UnityEngine.InputSystem.InputAction.CallbackContext obj)
    {
        // Play feedback only when grounded
        if (IsGrounded())
            FeedbackManager.Instance.GetPlayerJumpFeedback().PlayFeedbacks();

        jumpPressed = true;
    }

    // Called when the player dies
    private void HandleDeath()
    {
        Debug.Log("Player is dead");
        GameManager.Instance.GameOver();
    }
}

			
C#
				using UnityEngine;
using System.Collections;

public class BounceWalk : MonoBehaviour
{
    [SerializeField] private Transform visual;           // The visual element to bounce (e.g. player model)
    [SerializeField] private float bounceHeight = 0.2f;  // How high the bounce goes
    [SerializeField] private float bounceDuration = 0.4f;// Total duration of a full bounce cycle (up + down)
    [SerializeField] private float rotationAngle = 15f;  // Tilt angle during bounce

    private bool flip = false;                           // Used to alternate rotation direction
    [SerializeField] private bool canBounce = false;     // Whether bounce animation is allowed to play
    private Coroutine bounceLoop;                        // Reference to the bounce coroutine

    // Starts the bounce animation loop if it's not already running
    public void StartBounceLoop()
    {
        if (bounceLoop == null)
            bounceLoop = StartCoroutine(BounceLoop());
    }

    // Stops the bounce animation and resets position/rotation
    public void StopBounceLoop()
    {
        if (bounceLoop != null)
        {
            StopCoroutine(bounceLoop);
            bounceLoop = null;

            // Reset visual back to resting position and rotation
            visual.localPosition = Vector3.zero;
            visual.localRotation = Quaternion.identity;
        }
    }

    // Coroutine that continuously bounces the visual up/down and rotates it slightly
    private IEnumerator BounceLoop()
    {
        Vector3 basePos = visual.localPosition;

        while (canBounce)
        {
            // Calculate top position and alternating rotation
            Vector3 topPos = basePos + new Vector3(0, bounceHeight, 0);
            float targetAngle = flip ? -rotationAngle : rotationAngle;
            Quaternion startRot = Quaternion.identity;
            Quaternion peakRot = Quaternion.Euler(targetAngle, 0f, 0f);
            flip = !flip;

            // Bounce Up Phase
            float elapsed = 0f;
            while (elapsed < bounceDuration / 2f)
            {
                float t = elapsed / (bounceDuration / 2f);
                visual.localPosition = Vector3.Lerp(basePos, topPos, t);
                visual.localRotation = Quaternion.Lerp(startRot, peakRot, t);
                elapsed += Time.deltaTime;
                yield return null;
            }

            // Bounce Down Phase
            elapsed = 0f;
            while (elapsed < bounceDuration / 2f)
            {
                float t = elapsed / (bounceDuration / 2f);
                visual.localPosition = Vector3.Lerp(topPos, basePos, t);
                visual.localRotation = Quaternion.Lerp(peakRot, startRot, t);
                elapsed += Time.deltaTime;
                yield return null;
            }

            // Reset to avoid floating-point drift
            visual.localPosition = basePos;
            visual.localRotation = Quaternion.identity;
        }

        bounceLoop = null;
    }

    // External toggle to start or stop the bounce effect
    public void ToggleBounce(bool value)
    {
        canBounce = value;

        if (canBounce)
        {
            StartBounceLoop();
        }
        else
        {
            StopBounceLoop();
        }
    }
}

			
C#
				using UnityEngine;

public class PlatformHazard : MonoBehaviour, IMoveable
{
    [Header("Platform Hazard Settings")]
    [SerializeField] private bool isHazard = false;  // Toggle whether this platform should be harmful

    private Collider col;                 // Cached reference to the collider
    private float hazardSpeed;           // Movement speed
    private Vector3 hazardTarget;        // Target position to move toward
    private bool isMoving;               // Is this hazard currently moving?
    private float localTimeScale = 1f;   // Time scale modifier for this object (used to slow or speed up movement)

    // Called by other scripts to move this hazard to a new position
    public void MoveTo(Vector3 targetPos, float speed)
    {
        hazardTarget = targetPos;
        hazardSpeed = speed;
        isMoving = true;
        localTimeScale = 1f; // Reset time scale to normal
    }

    // Called when the object is created
    private void Awake()
    {
        col = GetComponent<Collider>();
        UpdateCollider(); // Enable/disable collider based on isHazard value
    }

    // Runs automatically in the Unity Editor when isHazard is changed in the inspector
    private void OnValidate() 
    {
        if (col == null) col = GetComponent<Collider>();
        UpdateCollider();
    }

    // Allows hazard state to be updated at runtime by other scripts
    public void SetHazard(bool hazardState)
    {
        isHazard = hazardState;
        UpdateCollider();
    }

    // Enable or disable the collider based on isHazard
    private void UpdateCollider()
    {
        col.enabled = isHazard;
    }

    // Called when something enters this object's collider
    private void OnTriggerEnter(Collider other)
    {
        // Check if the object has a PlayerDamageable component
        var hit = other.GetComponent<PlayerDamageable>();
        if (hit != null) 
        {
            hit.PlayerHit(); // Trigger damage
        }
    }

    // Set movement speed while it's already moving (for dynamic adjustments)
    public void SetSpeed(float newSpeed)
    {
        hazardSpeed = newSpeed;
    }

    // Set custom time scale for this object (e.g., slow motion or speed up)
    public void SetTimeScale(float t)
    {
        localTimeScale = t;
    }

    // Handles movement logic each frame
    private void Update()
    {
        if (!isMoving) return;

        float spd = hazardSpeed * Time.deltaTime * localTimeScale;

        transform.position = Vector3.MoveTowards(transform.position, hazardTarget, spd);

        // If the hazard reached its destination, destroy it
        if (Vector3.Distance(transform.position, hazardTarget) < 0.01f)
            Destroy(gameObject);
    }
}

			
C#
				using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Object = UnityEngine.Object;

public class ObstacleSpawner : MonoBehaviour
{
    [Header("Spawner Settings")]
    [SerializeField] private Transform startPos;     // Where the obstacle spawns
    [SerializeField] private Transform endPos;       // Where the obstacle should move to

    [Header("Obstacle Settings")]
    [SerializeField] private List<GameObject> obstacles = new List<GameObject>(); // List of obstacle prefabs
    [SerializeField] private float spawnInterval = 2f;   // Time between spawn attempts
    [SerializeField] private float spawnChance = 0.5f;   // Chance for an obstacle to spawn per interval
    [SerializeField] private float spawnDelay = 1f;      // Cooldown after a successful spawn

    [Header("Speed Settings")]
    [SerializeField] private float baseSpeed = 1f;       // Starting speed of obstacles
    [SerializeField] private float speedIncrement = .5f; // Speed increase per level/index change

    private float currentSpeed;                  // Current speed for new and existing obstacles
    private bool canSpawn = true;                // Whether spawning is currently allowed
    private Coroutine spawnRoutine;              // Reference to the active spawn coroutine

    private readonly List<IMoveable> activeObstacles = new List<IMoveable>(); // Optional: to keep track of obstacles

    private void Awake()
    {
        // Initialize starting speed
        currentSpeed = baseSpeed;
    }

    private void Start()
    {
        // Subscribe to index change event to increase speed dynamically
        ScoreManager.Instance.OnIndexChanged += OnIndexIncreased;
    }

    // Start the continuous spawning coroutine
    public void StartSpawning()
    {
        if (spawnRoutine == null)
            spawnRoutine = StartCoroutine(SpawnRoutine());
    }

    // Stop the continuous spawning coroutine
    public void StopSpawning()
    {
        if (spawnRoutine != null)
        {
            StopCoroutine(spawnRoutine);
            spawnRoutine = null;
        }
    }

    // Coroutine to attempt spawning obstacles at regular intervals
    private IEnumerator SpawnRoutine()
    {
        while (true)
        {
            TrySpawn();
            yield return new WaitForSeconds(spawnInterval);
        }
    }

    // Delay after each successful spawn to prevent immediate back-to-back spawns
    private IEnumerator ResetSpawnCooldown()
    {
        yield return new WaitForSeconds(spawnDelay);
        canSpawn = true;
    }

    // Try to spawn an obstacle based on chance and cooldown
    private void TrySpawn()
    {
        if (!canSpawn) return;

        if (Random.value <= spawnChance)
        {
            SpawnOne();
            canSpawn = false;
            StartCoroutine(ResetSpawnCooldown());
        }
    }

    // Instantiates a random obstacle from the list and moves it
    public void SpawnOne()
    {
        if (obstacles.Count == 0) return;

        GameObject obstacle = obstacles[Random.Range(0, obstacles.Count)];
        GameObject obstacleGO = Instantiate(obstacle, startPos.position, startPos.rotation);

        MoveObstacle(obstacleGO);
    }

    // Sends a MoveTo call to the obstacle so it starts moving toward the endpoint
    private void MoveObstacle(GameObject obj)
    {
        var moveable = obj.GetComponent<IMoveable>();
        if (moveable != null)
            moveable.MoveTo(endPos.position, currentSpeed);
    }

    /// <summary>
    /// Called every time the score index increases.
    /// - Speeds up all existing obstacles that implement IMoveable
    /// </summary>
    private void OnIndexIncreased(int newIndex)
    {
        // Increase movement speed for future and current obstacles
        currentSpeed += speedIncrement;

        // Find all MonoBehaviours in the scene
        var allBehaviours = Object.FindObjectsByType<MonoBehaviour>(FindObjectsSortMode.None);

        // Reapply MoveTo with the new speed to all live IMoveable objects
        foreach (var mb in allBehaviours)
        {
            if (mb is IMoveable mover)
            {
                mover.MoveTo(endPos.position, currentSpeed);
            }
        }
    }
}

			

Used assets