Source file unity-csharp/arcade-classics/pong/Ball2D.cs from the GDnD code examples. Download raw file

// GDnD wiki example
// Demonstrates: continuous movement, manual reflection off walls and paddles, and edge scoring — the step-1 Pong skills
// Related pages: [[overview-arcade-classics-as-learning-projects]], [[unity-collider2d-and-triggers]], [[monobehaviour-lifecycle]], [[game-feel]]
 
using System;
using UnityEngine;
 
// Transform-driven ball. No Rigidbody is needed: the whole point of Pong as a
// first project is to make collision and reflection explicit rather than hand
// them to the physics engine. Attach to a small square sprite.
public class Ball2D : MonoBehaviour
{
    [Header("Movement")]
    [SerializeField] private float startSpeed = 8f;
    [SerializeField] private float speedGainPerHit = 0.35f;
    [SerializeField] private float maxSpeed = 18f;
 
    [Header("Paddles")]
    [Tooltip("Colliders the ball reflects off. Usually the two paddle BoxCollider2Ds.")]
    [SerializeField] private Collider2D[] paddles;
 
    [Header("Feel")]
    [Tooltip("How strongly the contact point on the paddle angles the bounce (0 = pure mirror).")]
    [SerializeField, Range(0f, 1f)] private float paddleEnglish = 0.75f;
 
    // Raised with +1 (ball exited right → left player scores) or -1 (left player's goal).
    public event Action<int> Scored;
 
    private Vector2 velocity;
    private float halfSize;
    private Camera cachedCamera;
 
    private void Awake()
    {
        cachedCamera = Camera.main;
        halfSize = GetComponent<SpriteRenderer>().bounds.extents.y;
    }
 
    // Called by the match controller to (re)serve toward a given horizontal direction.
    public void Serve(int horizontalDirection)
    {
        float angle = UnityEngine.Random.Range(-0.5f, 0.5f); // gentle vertical spread
        velocity = new Vector2(Mathf.Sign(horizontalDirection), angle).normalized * startSpeed;
        transform.position = Vector3.zero;
    }
 
    private void Update()
    {
        transform.position += (Vector3)(velocity * Time.deltaTime);
        BounceOffWalls();
        BounceOffPaddles();
        CheckForScore();
    }
 
    // Reflect the y component when the ball passes the top/bottom of the screen.
    private void BounceOffWalls()
    {
        float topEdge = cachedCamera.orthographicSize - halfSize;
        if (transform.position.y > topEdge && velocity.y > 0f)
        {
            velocity.y = -velocity.y;
        }
        else if (transform.position.y < -topEdge && velocity.y < 0f)
        {
            velocity.y = -velocity.y;
        }
    }
 
    private void BounceOffPaddles()
    {
        if (paddles == null)
        {
            return;
        }
 
        foreach (Collider2D paddle in paddles)
        {
            if (paddle == null || !paddle.bounds.Contains(transform.position))
            {
                continue;
            }
 
            // Only reflect if we are heading into the paddle, so the ball cannot
            // get "stuck" reversing every frame while overlapping.
            bool movingIntoPaddle = Mathf.Sign(velocity.x) != Mathf.Sign(transform.position.x - paddle.bounds.center.x);
            if (!movingIntoPaddle)
            {
                continue;
            }
 
            // Where on the paddle did we hit? -1 = bottom edge, +1 = top edge.
            float offset = (transform.position.y - paddle.bounds.center.y) / paddle.bounds.extents.y;
 
            float speed = Mathf.Min(velocity.magnitude + speedGainPerHit, maxSpeed);
            Vector2 direction = new Vector2(-Mathf.Sign(velocity.x), offset * paddleEnglish).normalized;
            velocity = direction * speed;
            return;
        }
    }
 
    private void CheckForScore()
    {
        float sideEdge = cachedCamera.orthographicSize * cachedCamera.aspect + halfSize;
        if (transform.position.x > sideEdge)
        {
            Scored?.Invoke(+1);
        }
        else if (transform.position.x < -sideEdge)
        {
            Scored?.Invoke(-1);
        }
    }
}