Voltar para o Blog
Quest Log

Como Fazer um Endless Runner na Unity (3D)

Personagem 3D correndo numa pista de três faixas com barreiras e moedas à frente, cidade ao fundo

Endless runner na Unity em 3D: três faixas com swipe e teclado, chão infinito reciclado com pool, obstáculos aleatórios, velocidade crescente e pontuação.

Ferramenta:UnityC#

Como Fazer um Endless Runner na Unity (3D)

Um endless runner na Unity em 3D tem uma estrutura enxuta: o jogador troca entre três faixas, a pista vem em blocos que são reciclados para nunca acabar, obstáculos e moedas aparecem em posições aleatórias e a velocidade sobe com o tempo. Neste tutorial de unity endless runner você monta tudo isso em C# do Unity 6.3 LTS, com troca de faixa por teclado e por swipe, pooling dos blocos com UnityEngine.Pool e pontuação por distância.

E você resolve, desde o primeiro dia, um problema que só aparece depois de vários minutos de corrida: a perda de precisão de float longe da origem.

Estrutura do endless runner

Quatro scripts, cada um com uma responsabilidade:

  • RunnerPlayer: troca de faixa, pulo, colisão com obstáculo e moeda.
  • SwipeInput: transforma toques na tela em direções (esquerda, direita, cima).
  • TrackChunk: um bloco de pista com obstáculos e moedas que se reorganizam a cada uso.
  • TrackManager: mantém a fila de blocos, move a pista, recicla o que ficou para trás, controla velocidade e distância.

A decisão de arquitetura mais importante vem antes do código: o jogador fica parado no eixo Z e o mundo vem até ele. O motivo está na seção sobre precisão de float, no fim. Por enquanto, guarde: o jogador só se move em X (faixas) e Y (pulo).

Montar a cena

Antes dos scripts, a cena mínima:

  1. Crie o projeto com o template Universal 3D.
  2. Crie um GameObject vazio Track na origem. É nele que o TrackManager vai morar, e os blocos nascem como filhos dele.
  3. Crie o jogador Player na posição X 0, Y 0,5, Z 0 com um modelo simples (uma cápsula serve para testar) e coloque a câmera em X 0, Y 4, Z -7, rotação X de 20 graus, olhando para a pista.
  4. Crie um GameObject Input com o componente SwipeInput (script mais abaixo).
  5. Em Tags and Layers, crie as tags Obstacle e Coin.

Com a câmera fixa e o jogador parado em Z, a sensação de velocidade vem inteira da pista passando. Por isso, mais adiante, linhas no chão e objetos laterais nos blocos fazem tanta diferença: são a referência visual de quão rápido o mundo está vindo.

Jogador em três faixas

Crie o jogador com uma CapsuleCollider marcada como Is Trigger e um Rigidbody com Is Kinematic ligado (necessário para os triggers dispararem). A câmera fica atrás e acima, olhando para frente. Crie RunnerPlayer.cs:

using UnityEngine;
using UnityEngine.InputSystem;

public class RunnerPlayer : MonoBehaviour
{
    [SerializeField] private TrackManager track;
    [SerializeField] private SwipeInput swipeInput;
    [SerializeField] private float laneDistance = 2f;
    [SerializeField] private float laneChangeSpeed = 15f;
    [SerializeField] private float jumpVelocity = 9f;
    [SerializeField] private float gravity = 25f;

    private int currentLane = 1;
    private float verticalVelocity;
    private float groundY;
    private bool isDead;

    public int Coins { get; private set; }

    private void Awake()
    {
        groundY = transform.position.y;
    }

    private void OnEnable()
    {
        swipeInput.Swiped += HandleSwipe;
    }

    private void OnDisable()
    {
        swipeInput.Swiped -= HandleSwipe;
    }

    private void Update()
    {
        if (isDead)
        {
            return;
        }

        ReadKeyboard();

        Vector3 position = transform.position;
        float targetX = (currentLane - 1) * laneDistance;
        position.x = Mathf.MoveTowards(position.x, targetX, laneChangeSpeed * Time.deltaTime);

        verticalVelocity -= gravity * Time.deltaTime;
        position.y += verticalVelocity * Time.deltaTime;
        if (position.y <= groundY)
        {
            position.y = groundY;
            verticalVelocity = 0f;
        }

        transform.position = position;
    }

    private void ReadKeyboard()
    {
        Keyboard keyboard = Keyboard.current;
        if (keyboard == null)
        {
            return;
        }

        if (keyboard.leftArrowKey.wasPressedThisFrame || keyboard.aKey.wasPressedThisFrame)
        {
            ChangeLane(-1);
        }
        if (keyboard.rightArrowKey.wasPressedThisFrame || keyboard.dKey.wasPressedThisFrame)
        {
            ChangeLane(1);
        }
        if (keyboard.upArrowKey.wasPressedThisFrame || keyboard.spaceKey.wasPressedThisFrame)
        {
            Jump();
        }
    }

    private void HandleSwipe(Vector2Int direction)
    {
        if (isDead)
        {
            return;
        }

        if (direction.x != 0)
        {
            ChangeLane(direction.x);
        }
        else if (direction.y > 0)
        {
            Jump();
        }
    }

    private void ChangeLane(int delta)
    {
        currentLane = Mathf.Clamp(currentLane + delta, 0, 2);
    }

    private void Jump()
    {
        if (transform.position.y <= groundY + 0.01f)
        {
            verticalVelocity = jumpVelocity;
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        if (isDead)
        {
            return;
        }

        if (other.CompareTag("Obstacle"))
        {
            isDead = true;
            track.Stop();
        }
        else if (other.CompareTag("Coin"))
        {
            Coins++;
            other.gameObject.SetActive(false);
        }
    }
}

As faixas são índices 0, 1 e 2, e a posição X sai da conta (faixa - 1) * distância: -2, 0 e 2. A troca usa Mathf.MoveTowards, que leva o personagem até a faixa numa velocidade constante. Ficar no índice e calcular a posição evita o bug de faixa "entre faixas" quando o jogador aperta duas vezes rápido.

O pulo é calculado à mão (velocidade vertical menos gravidade a cada frame), sem Rigidbody dinâmico. Num runner, a física completa só atrapalha: você quer um arco de pulo sempre igual, ajustável por dois números.

Swipe com EnhancedTouch

No celular, o controle é o swipe. O Input System tem a API EnhancedTouch, que dá a posição inicial e atual de cada toque. Crie SwipeInput.cs:

using System;
using UnityEngine;
using UnityEngine.InputSystem.EnhancedTouch;
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;
using TouchPhase = UnityEngine.InputSystem.TouchPhase;

public class SwipeInput : MonoBehaviour
{
    [SerializeField] private float minSwipeFraction = 0.06f;

    public event Action<Vector2Int> Swiped;

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

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

    private void Update()
    {
        float minDistance = Screen.height * minSwipeFraction;

        foreach (Touch touch in Touch.activeTouches)
        {
            if (touch.phase != TouchPhase.Ended)
            {
                continue;
            }

            Vector2 delta = touch.screenPosition - touch.startScreenPosition;
            if (delta.magnitude < minDistance)
            {
                continue;
            }

            Vector2Int direction = Mathf.Abs(delta.x) > Mathf.Abs(delta.y)
                ? new Vector2Int(delta.x > 0f ? 1 : -1, 0)
                : new Vector2Int(0, delta.y > 0f ? 1 : -1);

            Swiped?.Invoke(direction);
        }
    }
}

Os dois using com alias evitam conflito de nome: existe um Touch antigo no UnityEngine e um TouchPhase também. A distância mínima é uma fração da altura da tela, não um número fixo de pixels, então o swipe funciona igual num celular pequeno e num tablet. O post de swipe no mobile com a Unity aprofunda swipe durante o movimento (sem esperar soltar o dedo) e o teste no editor.

Endless runner na Unity: gerar o chão infinito

Cada bloco de pista é um prefab de 20 unidades de comprimento, com o pivô no começo do bloco. Dentro dele, organize obstáculos e moedas em linhas de 3 (uma por faixa), na ordem linha por linha: índice 0, 1, 2 é a primeira linha; 3, 4, 5 a segunda, e assim por diante. Obstáculos com a tag Obstacle e collider; moedas com a tag Coin e collider Is Trigger. Crie TrackChunk.cs:

using UnityEngine;

public class TrackChunk : MonoBehaviour
{
    [SerializeField] private float length = 20f;
    [SerializeField] private GameObject[] obstacles;
    [SerializeField] private GameObject[] coins;

    public float Length => length;

    public void Setup(float obstacleChance)
    {
        int rows = obstacles.Length / 3;
        for (int row = 0; row < rows; row++)
        {
            int freeLane = Random.Range(0, 3);
            for (int lane = 0; lane < 3; lane++)
            {
                int index = row * 3 + lane;
                bool blocked = lane != freeLane && Random.value < obstacleChance;
                obstacles[index].SetActive(blocked);

                if (index < coins.Length)
                {
                    coins[index].SetActive(lane == freeLane);
                }
            }
        }
    }

    public void Clear()
    {
        foreach (GameObject obstacle in obstacles)
        {
            obstacle.SetActive(false);
        }
        foreach (GameObject coin in coins)
        {
            coin.SetActive(false);
        }
    }
}

A regra que garante que o jogo é justo: em cada linha, uma faixa é sorteada como livre e nunca recebe obstáculo. As moedas aparecem justamente na faixa livre, o que ensina o jogador a seguir as moedas. Nenhuma linha fecha as três faixas, nunca.

Velocidade crescente e pontuação

O TrackManager move a pista, recicla os blocos e cuida de velocidade e distância. Crie TrackManager.cs:

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;

public class TrackManager : MonoBehaviour
{
    [SerializeField] private TrackChunk chunkPrefab;
    [SerializeField] private int chunksAhead = 6;
    [SerializeField] private int safeChunks = 2;
    [SerializeField] private float startSpeed = 10f;
    [SerializeField] private float maxSpeed = 30f;
    [SerializeField] private float speedGainPerSecond = 0.25f;
    [SerializeField] private float startObstacleChance = 0.3f;
    [SerializeField] private float maxObstacleChance = 0.7f;

    private readonly List<TrackChunk> active = new List<TrackChunk>();
    private ObjectPool<TrackChunk> pool;
    private float speed;
    private bool running = true;
    private int spawnedCount;

    public float Distance { get; private set; }
    public float Speed => speed;

    private void Awake()
    {
        pool = new ObjectPool<TrackChunk>(
            createFunc: () => Instantiate(chunkPrefab, transform),
            actionOnGet: c => c.gameObject.SetActive(true),
            actionOnRelease: c => c.gameObject.SetActive(false),
            actionOnDestroy: c => Destroy(c.gameObject),
            collectionCheck: true,
            defaultCapacity: chunksAhead + 2,
            maxSize: chunksAhead + 4);

        speed = startSpeed;
        float z = 0f;
        for (int i = 0; i < chunksAhead; i++)
        {
            TrackChunk chunk = SpawnChunk(z);
            z += chunk.Length;
        }
    }

    private void Update()
    {
        if (!running)
        {
            return;
        }

        speed = Mathf.Min(maxSpeed, speed + speedGainPerSecond * Time.deltaTime);
        float step = speed * Time.deltaTime;
        Distance += step;

        foreach (TrackChunk chunk in active)
        {
            chunk.transform.position += Vector3.back * step;
        }

        TrackChunk first = active[0];
        if (first.transform.position.z + first.Length < -first.Length)
        {
            active.RemoveAt(0);
            first.Clear();
            pool.Release(first);

            TrackChunk last = active[active.Count - 1];
            SpawnChunk(last.transform.position.z + last.Length);
        }
    }

    private TrackChunk SpawnChunk(float z)
    {
        TrackChunk chunk = pool.Get();
        chunk.transform.position = new Vector3(0f, 0f, z);

        float progress = Mathf.InverseLerp(startSpeed, maxSpeed, speed);
        float chance = Mathf.Lerp(startObstacleChance, maxObstacleChance, progress);

        if (spawnedCount < safeChunks)
        {
            chunk.Clear();
        }
        else
        {
            chunk.Setup(chance);
        }

        spawnedCount++;
        active.Add(chunk);
        return chunk;
    }

    public void Stop()
    {
        running = false;
    }
}

A pontuação é a própria Distance, somada a cada frame com o quanto a pista andou. Mostre Mathf.FloorToInt(track.Distance) num texto TextMeshPro e some as moedas do jogador como bônus.

A dificuldade sobe por dois caminhos ligados entre si: a velocidade cresce 0,25 por segundo até o teto, e a chance de obstáculo acompanha o progresso da velocidade. Os dois primeiros blocos saem sempre vazios, para o jogador ter um respiro no começo.

Com o loop básico pronto, aparece a parte que faz um runner parecer jogo de loja: animação de corrida que troca para pulo e deslize, câmera que balança na troca de faixa, partículas, som, tela de game over com recorde, loja de personagens. Cada uma é simples isolada, e o desafio é encaixar tudo sem quebrar o que funciona. Montar um jogo inteiro assim, do protótipo ao build, é o que um curso de Unity com projetos completos faz você praticar.

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

Pooling dos blocos

O ObjectPool<TrackChunk> mantém no máximo chunksAhead + 4 blocos guardados. Na prática, a corrida inteira usa sempre os mesmos 6 ou 7 blocos: o que sai por trás volta para a frente com obstáculos novos. Sem pool, você criaria e destruiria um bloco a cada segundo, gerando lixo para o garbage collector e engasgos que, num runner, são exatamente o momento em que o jogador bate.

Dois cuidados: Clear() antes de devolver, para o bloco não voltar com moedas já coletadas desativadas de forma inconsistente, e Setup() sempre ao tirar do pool, para a combinação de obstáculos mudar. A teoria geral de pooling está em object pooling e otimização.

Para variedade, troque o prefab único por uma lista de prefabs (reta, curva visual, ponte, túnel) e mantenha um pool para cada um, sorteando qual vem.

Precisão float: mover o mundo ou reposicionar a origem

O float de 32 bits tem cerca de 7 dígitos significativos. Perto da origem, a menor diferença entre dois valores representáveis é minúscula. Longe dela, cresce: entre 65.536 e 131.072, por exemplo, o passo mínimo já é de 0,0078 unidade. A 30 unidades por segundo, o jogador passa de 65 mil unidades em pouco mais de meia hora. A partir daí, posições de câmera e de objetos começam a "pular" em degraus visíveis, a física fica imprecisa e a câmera treme.

Duas soluções:

  1. Mover o mundo (a deste tutorial): o jogador fica em Z = 0 e a pista vem até ele. Os blocos nascem sempre perto da origem e somem logo atrás dela. Nenhuma coordenada cresce. A distância percorrida é só um número de pontuação, que pode ser float sem problema visual (ou double, se você quiser precisão de centímetro em corridas de horas).
  2. Reposicionar a origem (floating origin): o jogador anda de verdade e, a cada N unidades (por exemplo, 1.000), tudo na cena é deslocado de volta em -N. Funciona, mas exige cuidado com partículas, trilhas e física, que precisam ser deslocadas juntas.

Para runner, mover o mundo é mais simples e resolve de vez.

Erros comuns e polimento

Os triggers não disparam. O jogador está sem Rigidbody (marque Is Kinematic) ou os obstáculos não têm a tag certa.

Aparece um buraco entre os blocos. O pivô do prefab não está no começo do bloco ou o length não bate com o tamanho real do modelo.

O swipe não funciona no editor. Use o Device Simulator (Window > General > Device Simulator) ou teste no aparelho. O teclado continua funcionando no editor.

O jogo engasga a cada bloco novo. Algo está sendo instanciado em Setup. Tudo que o bloco usa deve já existir no prefab, só ativado e desativado.

Obstáculo impossível de evitar na troca de faixa. Em velocidade alta, uma linha bloqueada logo depois de outra com a faixa livre do lado oposto exige duas trocas em menos tempo do que o laneChangeSpeed permite. Aumente o espaço entre linhas conforme a velocidade sobe, ou limite a faixa livre de uma linha a no máximo uma faixa de distância da anterior.

Para o jogo, mas a música e as moedas continuam. O Stop() só para a pista. Na tela de game over, desligue também o input do jogador e a animação, centralizando tudo num evento de fim de partida.

Para a versão Godot do mesmo projeto, com a mesma lógica de blocos reciclados, veja como fazer um endless runner no Godot.

Perguntas frequentes

Como fazer o chão infinito de um endless runner na Unity?

Divida a pista em blocos de tamanho fixo, mantenha alguns blocos à frente do jogador e, quando um bloco fica para trás, devolva ao pool e coloque um novo no fim da fila. O jogador nunca vê a pista acabar e o número de objetos na cena fica constante.

É melhor mover o jogador ou mover o mundo no endless runner?

Mover o mundo em direção ao jogador parado mantém todas as coordenadas perto da origem, o que evita os problemas de precisão de float em corridas longas. Mover o jogador é mais intuitivo, mas exige reposicionar tudo de volta à origem de tempos em tempos.

Como detectar swipe na Unity com o Input System?

Ative o EnhancedTouch com EnhancedTouchSupport.Enable(), percorra Touch.activeTouches e, quando um toque terminar, compare a posição final com a inicial. Se a distância passar de um mínimo, o eixo com maior deslocamento define a direção do swipe.

Como aumentar a dificuldade do endless runner com o tempo?

Aumente a velocidade aos poucos até um teto e, junto, aumente a chance de obstáculos por bloco. Subir os dois devagar mantém a curva justa; subir rápido demais faz o jogador morrer sem entender por quê.