Voltar para o Blog
Quest Log

Como Fazer Pong na Unity: Física, Placar e IA

Campo de Pong com duas raquetes brancas, a bola em diagonal e o placar no topo sobre fundo preto

Como fazer Pong na Unity: bola com Physics Material 2D sem atrito, ângulo de rebote pela raquete, velocidade constante, placar e IA vencível, em C# do Unity 6.

Ferramenta:UnityC#

Como Fazer Pong na Unity: Física, Placar e IA

Fazer Pong na Unity parece trivial até a bola começar a perder velocidade, ficar presa num vai e vem horizontal ou rebater sempre no mesmo ângulo. Este tutorial de unity pong monta o jogo com física controlada: bola com Physics Material 2D sem atrito, velocidade normalizada a cada passo, ângulo de rebote definido pelo ponto em que a bola bate na raquete, placar com reinício do ponto, IA com atraso e erro para ser vencível e modo de dois jogadores no mesmo teclado. Tudo em C# do Unity 6.3 LTS.

Por que fazer Pong

Pong é o "Olá, mundo" dos jogos, e por um bom motivo: ele força você a lidar com física, input, regra de pontuação, reinício de estado e IA, tudo em um projeto que cabe numa tarde. E ensina uma lição que vale para qualquer jogo com física: a engine simula, mas quem decide a sensação é você. Deixar a física da Unity resolver o rebote sozinha dá um Pong sem graça. Controlar o rebote dá um Pong em que o jogador tem habilidade.

Cena: campo, raquetes e bola

Crie o projeto com o template Universal 2D e monte a cena com o sprite quadrado (Create > 2D > Sprites > Square):

  1. Câmera: Size 5, fundo preto. O campo visível tem 10 unidades de altura e cerca de 17,8 de largura em 16:9.
  2. Paredes: dois objetos WallTop e WallBottom em Y 5,25 e Y -5,25, escala 18 x 0,5, com BoxCollider2D.
  3. Raquetes: PaddleLeft em X -8 e PaddleRight em X 8, escala 0,3 x 2. Cada uma com BoxCollider2D e Rigidbody2D em Body Type Kinematic.
  4. Gols: GoalLeft em X -9,5 e GoalRight em X 9,5, escala 0,5 x 12, com BoxCollider2D marcado como Is Trigger e sem sprite.
  5. Bola: escala 0,3 x 0,3, CircleCollider2D e Rigidbody2D (configurado na próxima seção).
  6. Placar: dois textos TextMeshPro no topo do Canvas.

Raquete Kinematic porque ela é movida pelo código e não deve ser empurrada pela bola. Corpo Kinematic não sofre força nem gravidade, mas colide com corpos Dynamic como a bola.

Bola com Rigidbody2D e Physics Material 2D

No Project, Create > 2D > Physics Material 2D, nome Bouncy. Configure Friction 0 e Bounciness 1. Bounciness 1 é um quique sem perda de energia; Friction 0 impede a bola de "rolar" na parede e mudar de direção sozinha.

Aplique o material no collider da bola e das paredes. Pela documentação do Physics Material 2D no Unity 6.3, quando dois colliders se tocam, a combinação padrão do Bounciness é Maximum e a do Friction é Mean. Isso quer dizer que basta um dos dois ter Bounciness 1 para o quique ser total, e a média geométrica do atrito com a bola em 0 também dá 0. Na prática, aplicar só na bola já funcionaria, mas aplicar nas paredes também deixa a intenção explícita.

No Rigidbody2D da bola: Gravity Scale 0, Collision Detection Continuous, Interpolate, Freeze Rotation Z marcado. Crie Ball.cs:

using UnityEngine;

[RequireComponent(typeof(Rigidbody2D))]
public class Ball : MonoBehaviour
{
    [SerializeField] private float startSpeed = 8f;
    [SerializeField] private float speedIncrease = 0.4f;
    [SerializeField] private float maxSpeed = 18f;
    [SerializeField] private float maxBounceAngle = 60f;
    [SerializeField] private float minHorizontalRatio = 0.35f;

    private Rigidbody2D rb;
    private float speed;

    public Vector2 Velocity => rb.linearVelocity;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
    }

    public void ResetBall()
    {
        rb.linearVelocity = Vector2.zero;
        rb.position = Vector2.zero;
        transform.position = Vector3.zero;
    }

    public void Launch(float horizontalSign)
    {
        speed = startSpeed;
        float angle = Random.Range(-30f, 30f) * Mathf.Deg2Rad;
        Vector2 direction = new Vector2(Mathf.Cos(angle) * horizontalSign, Mathf.Sin(angle));
        rb.linearVelocity = direction * speed;
    }

    private void FixedUpdate()
    {
        Vector2 velocity = rb.linearVelocity;
        if (velocity.sqrMagnitude < 0.0001f)
        {
            return;
        }

        Vector2 direction = velocity.normalized;
        if (Mathf.Abs(direction.x) < minHorizontalRatio)
        {
            float sign = direction.x >= 0f ? 1f : -1f;
            direction.x = minHorizontalRatio * sign;
            direction = direction.normalized;
        }

        rb.linearVelocity = direction * speed;
    }

    private void OnCollisionEnter2D(Collision2D collision)
    {
        Paddle paddle = collision.collider.GetComponent<Paddle>();
        if (paddle == null)
        {
            return;
        }

        Bounds bounds = collision.collider.bounds;
        float offset = (rb.position.y - bounds.center.y) / bounds.extents.y;
        offset = Mathf.Clamp(offset, -1f, 1f);

        float angle = offset * maxBounceAngle * Mathf.Deg2Rad;
        float awaySign = rb.position.x > bounds.center.x ? 1f : -1f;

        speed = Mathf.Min(speed + speedIncrease, maxSpeed);
        Vector2 direction = new Vector2(Mathf.Cos(angle) * awaySign, Mathf.Sin(angle));
        rb.linearVelocity = direction * speed;
    }
}

Mesmo com Bounciness 1, a velocidade é reescrita em todo FixedUpdate: direção normalizada * speed. Pequenas perdas numéricas da simulação se acumulam ao longo de um rally longo, e a bola fica visivelmente mais lenta sem você entender por quê. Normalizar resolve de vez. No Unity 6 a propriedade é linearVelocity; o velocity dos tutoriais antigos está obsoleto.

O minHorizontalRatio impede o outro bug clássico: a bola quase vertical, quicando entre as paredes de cima e de baixo sem nunca chegar às raquetes. Se o componente X da direção ficar abaixo de 0,35, ele é forçado para 0,35 e a direção é normalizada de novo.

Pong na Unity: ângulo de rebote pela raquete

Aqui está o que transforma Pong em jogo de habilidade. Quando a bola bate na raquete, o código ignora o rebote calculado pela física e define a direção pela posição do impacto:

  • offset vai de -1 (bateu na ponta de baixo) a 1 (ponta de cima), calculado pela distância entre a bola e o centro do collider da raquete, dividida pela metade da altura (bounds.extents.y).
  • O ângulo é offset * 60 graus. Bateu no centro, a bola sai reta. Bateu na ponta, sai a 60 graus.
  • A direção é montada com Cos no eixo X (com o sinal apontando para longe da raquete) e Sin no Y. Como cosseno e seno de um mesmo ângulo formam um vetor de comprimento 1, a direção já sai normalizada.

O jogador aprende a mirar: rebater com a ponta para mandar a bola na diagonal, com o centro para devolver reto e rápido. A cada rebatida a bola ganha 0,4 de velocidade até o teto de 18, e o rally fica mais tenso sozinho. Se cosseno e seno ainda parecem mágica, o post de matemática para jogos e vetores explica o círculo trigonométrico com exemplos de jogo.

Placar e reinício do ponto

Crie Goal.cs para os gols:

using UnityEngine;

public class Goal : MonoBehaviour
{
    [SerializeField] private PongGame game;
    [SerializeField] private bool isLeftGoal;

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (other.GetComponent<Ball>() != null)
        {
            game.ScorePoint(scoredByLeft: !isLeftGoal);
        }
    }
}

E PongGame.cs com placar, saque e fim de partida:

using System.Collections;
using TMPro;
using UnityEngine;

public class PongGame : MonoBehaviour
{
    [SerializeField] private Ball ball;
    [SerializeField] private TMP_Text leftScoreText;
    [SerializeField] private TMP_Text rightScoreText;
    [SerializeField] private TMP_Text messageText;
    [SerializeField] private int pointsToWin = 7;
    [SerializeField] private float serveDelay = 1f;

    private int leftScore;
    private int rightScore;
    private bool pointInProgress;

    private void Start()
    {
        UpdateScore();
        StartCoroutine(Serve(Random.value < 0.5f ? -1f : 1f));
    }

    public void ScorePoint(bool scoredByLeft)
    {
        if (!pointInProgress)
        {
            return;
        }
        pointInProgress = false;

        if (scoredByLeft)
        {
            leftScore++;
        }
        else
        {
            rightScore++;
        }
        UpdateScore();
        ball.ResetBall();

        if (leftScore >= pointsToWin || rightScore >= pointsToWin)
        {
            messageText.SetText(scoredByLeft ? "Esquerda venceu!" : "Direita venceu!");
            return;
        }

        float serveTowards = scoredByLeft ? 1f : -1f;
        StartCoroutine(Serve(serveTowards));
    }

    private IEnumerator Serve(float direction)
    {
        messageText.SetText(string.Empty);
        yield return new WaitForSeconds(serveDelay);
        pointInProgress = true;
        ball.Launch(direction);
    }

    private void UpdateScore()
    {
        leftScoreText.SetText("{0}", leftScore);
        rightScoreText.SetText("{0}", rightScore);
    }
}

O saque vai na direção de quem sofreu o ponto, como no Pong original: quem perdeu recebe a bola e tem a chance de reagir. A flag pointInProgress impede que a bola marque dois pontos se encostar no trigger duas vezes antes de ser recentralizada.

Com física, placar e saque funcionando, Pong deixa de ser exercício e vira jogo. E é aqui que o próximo passo pesa: menu, pausa, sons, efeitos, build e publicação. Fazer esse caminho do protótipo ao jogo terminado, várias vezes, com projetos que crescem em complexidade, é o que um curso de Unity com projetos completos organiza para você.

Curso de Unity
Quer fazer seu primeiro jogo na Unity?

No curso de Unity 6 da CursoGame.Dev você vai do zero ao primeiro jogo, com um desafio prático em cada level e suporte individual no WhatsApp quando travar.

Conhecer o curso de Unity
Suporte no WhatsAppAcesso vitalícioGarantia de 7 dias

IA de raquete com atraso e erro

Primeiro, a raquete controlável, em Paddle.cs. Ela recebe os bindings de teclado como texto, então o mesmo script serve para os dois jogadores:

using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(Rigidbody2D))]
public class Paddle : MonoBehaviour
{
    [SerializeField] private float speed = 10f;
    [SerializeField] private float limitY = 4f;
    [SerializeField] private bool controlledByPlayer = true;
    [SerializeField] private string upKey = "<Keyboard>/w";
    [SerializeField] private string downKey = "<Keyboard>/s";

    private Rigidbody2D rb;
    private InputAction moveAction;
    private float aiInput;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        moveAction = new InputAction("Move", InputActionType.Value);
        moveAction.AddCompositeBinding("1DAxis")
            .With("Negative", downKey)
            .With("Positive", upKey);
    }

    private void OnEnable()
    {
        moveAction.Enable();
    }

    private void OnDisable()
    {
        moveAction.Disable();
    }

    public void SetAIInput(float value)
    {
        aiInput = Mathf.Clamp(value, -1f, 1f);
    }

    private void FixedUpdate()
    {
        float input = controlledByPlayer ? moveAction.ReadValue<float>() : aiInput;
        float y = Mathf.Clamp(rb.position.y + input * speed * Time.fixedDeltaTime, -limitY, limitY);
        rb.MovePosition(new Vector2(rb.position.x, y));
    }
}

MovePosition é o jeito certo de mover corpo Kinematic: a física calcula a colisão com a bola durante o movimento, em vez de teleportar a raquete por cima dela.

Agora a IA, em PaddleAI.cs, que só "aperta os botões" da raquete:

using UnityEngine;

[RequireComponent(typeof(Paddle))]
public class PaddleAI : MonoBehaviour
{
    [SerializeField] private Ball ball;
    [SerializeField] private float reactionTime = 0.18f;
    [SerializeField] private float aimError = 0.6f;
    [SerializeField] private float deadZone = 0.15f;

    private Paddle paddle;
    private float targetY;
    private float nextDecisionTime;

    private void Awake()
    {
        paddle = GetComponent<Paddle>();
    }

    private void Update()
    {
        if (Time.time >= nextDecisionTime)
        {
            nextDecisionTime = Time.time + reactionTime;
            bool ballComing = Mathf.Sign(ball.Velocity.x) == Mathf.Sign(transform.position.x - ball.transform.position.x);
            targetY = ballComing
                ? ball.transform.position.y + Random.Range(-aimError, aimError)
                : 0f;
        }

        float difference = targetY - transform.position.y;
        paddle.SetAIInput(Mathf.Abs(difference) < deadZone ? 0f : Mathf.Sign(difference));
    }
}

Três limites tornam a IA humana:

  • Tempo de reação: ela só decide para onde ir a cada 0,18 s. Entre uma decisão e outra, persegue um alvo velho.
  • Erro de mira: o alvo é a altura da bola mais um desvio aleatório de até 0,6 unidade. Às vezes ela pega com a ponta, às vezes erra por pouco.
  • Mesma velocidade de raquete do jogador: com a bola acelerando a cada rebatida, chega um ponto em que a IA não alcança, e é aí que o jogador vence.

Quando a bola vai embora, a IA volta para o centro, como um jogador faria. Ajuste reactionTime e aimError para criar níveis: 0,1 e 0,3 é difícil; 0,3 e 1,0 é fácil.

Dois jogadores no mesmo teclado

Na raquete esquerda, deixe <Keyboard>/w e <Keyboard>/s. Na direita, troque para <Keyboard>/upArrow e <Keyboard>/downArrow, marque Controlled By Player e desative o PaddleAI. Cada raquete cria a própria InputAction com um composite 1D Axis, que devolve -1, 0 ou 1. O Input System lê as duas ações ao mesmo tempo, sem uma bloquear a outra.

Para alternar entre "contra a IA" e "dois jogadores" num menu, basta ligar e desligar o PaddleAI e o controlledByPlayer da raquete direita.

Um cuidado com teclado: alguns teclados de membrana não registram certas combinações de muitas teclas apertadas ao mesmo tempo (o chamado ghosting). W, S e as setas costumam funcionar juntas sem problema, mas se você trocar os bindings para letras vizinhas e algum jogador reclamar que a raquete "travou", teste em outro teclado antes de caçar bug no código. Para controles, adicione um segundo binding em cada ação apontando para o analógico do gamepad.

Erros comuns: bola perdendo velocidade, ficando horizontal

A bola perde velocidade. Falta o Physics Material 2D com Friction 0 e Bounciness 1, ou falta a normalização no FixedUpdate.

A bola fica num vai e vem horizontal perfeito. O ângulo de rebote não está sendo aplicado (o Paddle não foi encontrado no collider) e a bola sempre bate no centro. Confira se o script Paddle está no mesmo objeto do collider.

A bola fica quicando na vertical. Falta o minHorizontalRatio.

A bola atravessa a raquete. Collision Detection da bola em Discrete, ou a raquete sendo movida por transform.position em vez de MovePosition.

A raquete é empurrada pela bola. Body Type da raquete está como Dynamic. Use Kinematic.

Para aprofundar os parâmetros do material de física (quique, atrito e os modos de combinação), leia Physics Material na Unity: quique e atrito. E se você quer mostrar Pong para alguém que ainda não programa, o Pong no Scratch é o mesmo jogo em blocos, ótimo para comparar a lógica.

Perguntas frequentes

Por que a bola do Pong perde velocidade na Unity?

Atrito e perda de energia no quique. Crie um Physics Material 2D com Friction 0 e Bounciness 1, aplique na bola e nas paredes, e ainda assim normalize a velocidade a cada passo de física para um valor fixo, porque pequenas perdas numéricas se acumulam.

Como fazer a bola rebater em ângulos diferentes na raquete?

Calcule onde a bola bateu em relação ao centro da raquete, de -1 (ponta de baixo) a 1 (ponta de cima), multiplique por um ângulo máximo (por exemplo, 60 graus) e monte a nova direção com cosseno e seno desse ângulo. Assim o jogador controla o rebote pela parte da raquete que usa.

Como fazer uma IA de Pong que dá para vencer?

Limite a velocidade da raquete da IA, faça ela só reagir a cada fração de segundo (tempo de reação) e some um erro aleatório ao ponto que ela persegue. Uma IA que segue a bola perfeitamente nunca perde, e jogo que não dá para vencer não tem graça.

Dá para fazer Pong com dois jogadores no mesmo teclado na Unity?

Sim. Crie uma InputAction por raquete com um composite 1D Axis: W e S para o jogador da esquerda, seta para cima e seta para baixo para o da direita. O Input System lê as duas ao mesmo tempo sem conflito.