PongExplained
Lo que hemos hecho
- Un Pong para dos jugadores en muy poco tiempo
- Casi todo sin programar: GameObjects y componentes
- Solo cuatro scripts cortos:
Paddle,Ball,GoalyGameManager - Unity pone el resto: dibujo, física, colisiones y teclado
En la sesión anterior programamos en directo una versión del Pong para dos jugadores. Se hizo deprisa y casi sin explicaciones, para que se viera lo rápido que se llega a un juego que funciona gracias a Unity.
Si lo piensas, escribimos muy poco código: cuatro scripts cortos (Paddle,
Ball, Goal y GameManager). Todo lo demás (dibujar los objetos, hacer que
la bola rebote, detectar que ha entrado en una meta, leer el teclado) lo hace
el motor. Nosotros solo creamos objetos, les añadimos piezas ya hechas y las
configuramos en el Inspector.
En esta página repasamos con calma qué hicimos en cada paso y por qué. Después, en la Actividad 3.1 · PongExpress, lo construirás tú paso a paso.

GameObjects
- Todo lo que hay en la Jerarquía es un GameObject
- Cámara, luz, bola, palas, muros, metas…
- Por sí solo no hace nada: es un contenedor de componentes
- Propiedades propias:
- Nombre
- Etiqueta (Tag)
- Capa (Layer)
- Activo o inactivo (casilla junto al nombre)
Todos los objetos que aparecen en la Jerarquía son GameObjects (objetos de
la clase GameObject): la cámara, la luz global, la bola, las palas, los
muros, las metas e incluso GameField y GameManager, que no se ven.
Un GameObject, por sí solo, no hace nada. Es un contenedor: lo que hace depende de los componentes que lleve dentro. Lo único que tiene propio son unos pocos datos que se ven en la parte superior del Inspector:
- Nombre, con el que aparece en la Jerarquía.
- Etiqueta (Tag), una palabra que sirve para reconocer el objeto desde el código. La usamos para saber que lo que entraba en la meta era la bola.
- Capa (Layer), que agrupa objetos para que algunos sistemas los traten de forma distinta (por ejemplo, para decidir qué capas chocan entre sí).
- La casilla junto al nombre, que lo activa o desactiva. Un GameObject inactivo no se dibuja y sus componentes no funcionan. Es lo que hicimos con la luz global en la versión 2.
Componentes
- Cada componente da al GameObject una capacidad
- Se añaden con Add Component en el Inspector
- Nuestros scripts también son componentes
Cada componente añade una capacidad al GameObject: dibujarse, chocar, moverse con la física… Se añaden desde el Inspector con el botón Add Component, y cada uno tiene sus propiedades, que se configuran ahí mismo. Los scripts que escribimos también son componentes: se añaden igual y sus variables públicas aparecen en el Inspector.
Así se construyó el juego, combinando componentes:
| Componente | Qué aporta | Quién lo lleva |
|---|---|---|
| Transform | Posición, rotación y escala | Todos |
| Sprite Renderer | Dibuja una imagen | Bola, palas, muros, metas y línea central |
| Box Collider 2D | Forma para chocar o detectar | Bola, palas, muros y metas |
| Rigidbody 2D | Movimiento con física | Bola y palas |
| Scripts | Comportamiento propio | Bola, palas, metas y GameManager |
La cámara
- Background: color de fondo (negro)
- Proyección ortográfica: sin perspectiva, ideal para 2D
- Size: la mitad de la altura que ve la cámara
- Size 5 → 10 unidades de alto
- El ancho depende de la relación de aspecto
- 16:9 → unas 17,8 unidades (de −8,9 a 8,9)
La cámara decide qué parte de la escena se ve en la pestaña Game. En su componente Camera cambiamos el color de fondo (Background, dentro de Environment) a negro.
En un proyecto 2D, la cámara usa una proyección ortográfica: los objetos se ven del mismo tamaño estén más cerca o más lejos, sin perspectiva. En ese modo, la propiedad Size es la mitad de la altura que ve la cámara, en unidades de Unity (las veremos con el Transform). Si vale 5, la cámara ve 10 unidades de alto, de −5 a 5. El ancho depende de la relación de aspecto de la pantalla: en Full HD (16:9), son 10 × 16 / 9 ≈ 17,8 unidades, de −8,9 a 8,9.
Sprite Renderer
- Dibuja una imagen (sprite) en la posición del objeto
- Color: tiñe la imagen (blanco = sin cambios)
- Order in Layer: el número mayor se dibuja delante
- Bola a 1: ni la línea central ni la estela la tapan
Al crear un objeto con 2D Object › Sprites › Square, Unity le añade un componente Sprite Renderer, que es el que dibuja la imagen (el sprite) en la posición del objeto. Su propiedad Color tiñe la imagen: como nuestro cuadrado es blanco, sirve para darle a cada elemento el color que queramos.
Cuando dos sprites se superponen, Order in Layer decide cuál se ve: el de número mayor se dibuja por delante. Todos los objetos empiezan con 0, y con el mismo número no está garantizado cuál queda delante: la línea central podría tapar la bola al cruzarse. Lo resolvimos en la versión 3, cuando la estela empezó a pintarse encima de la bola: con la bola a 1, queda siempre delante.
Transform
- Todos los GameObjects tienen uno
- Position, Rotation y Scale
- Se mide en unidades de Unity, no en píxeles
- Square mide 1 × 1: la escala es el tamaño
Ball, 0,5 × 0,5;Player1, 0,5 × 2
- Muros en Y = ±5, palas en X = ±8
- Metas en X = ±10: fuera de la vista
El Transform es el único componente que tienen todos los GameObjects y no se puede quitar. Guarda dónde está el objeto (Position), cómo está girado (Rotation) y su tamaño relativo (Scale), en los tres ejes. En un juego 2D, la Z de la posición casi no se usa, y la de la escala no cambia nada.
Las posiciones no se miden en píxeles, sino en unidades de Unity. Los objetos del juego los creamos con el sprite Square que trae Unity (2D Object › Sprites › Square), un cuadrado blanco que mide exactamente 1 × 1 unidades. Con escala 1, el objeto mide 1 × 1; con escala (0,5, 2), como las palas, mide 0,5 de ancho y 2 de alto. La escala es, directamente, el tamaño.
Con las cuentas de la cámara se entienden las posiciones que usamos. Los muros, en Y = ±5, quedan centrados en el borde superior e inferior: se ve la mitad de su grosor. Las palas, en X = ±8, cerca de los laterales. Y las metas, en X = ±10, fuera de la vista: la bola desaparece de la pantalla antes de marcar.
Colliders
- Box Collider 2D: la forma del objeto para la física (una caja)
- Sin collider, los objetos se atraviesan
MiddleLineno lleva: la bola pasa por encima
- Is Trigger (en las metas):
- No choca: deja pasar
- Avisa de que algo ha entrado
Para la física, un objeto no tiene la forma de su imagen: tiene la forma de su collider. Usamos Box Collider 2D, que es una caja del tamaño del sprite. Un objeto sin collider no choca con nada; por eso la línea central no lleva: la bola tiene que pasar por encima.
Las metas sí llevan collider, pero con Is Trigger marcado. Un collider así (un trigger) no hace rebotar nada: deja pasar a los objetos y avisa cuando uno entra. Es justo lo que necesitamos para saber que se ha marcado un punto sin que la bola rebote en la meta.

Rigidbody 2D
- El motor de física pasa a mover el objeto
- Solo en lo que se mueve: bola y palas
- Gravity Scale = 0: que no caigan
- Muros y metas sin Rigidbody: colliders estáticos
El componente Rigidbody 2D pone el objeto bajo el control del motor de física: a partir de ese momento es la física quien calcula su posición, aplicando velocidades, fuerzas y choques. Se lo pusimos a la bola y a las palas, que son lo único que se mueve.
Su propiedad Gravity Scale multiplica la gravedad. La pusimos a 0 porque vemos la mesa desde arriba: si no, la bola y las palas caerían hacia abajo. Más adelante, al probar la bola, ajustaremos otras dos cosas de la física: el material y los Constraints.
Los muros y las metas no llevan Rigidbody 2D. Un collider sin Rigidbody 2D es estático: no se mueve, pero los objetos con Rigidbody chocan con él. Eso sí, para que haya choque o aviso de trigger, al menos uno de los dos objetos debe tener Rigidbody 2D. En nuestro juego, la bola lo tiene.

Padres e hijos
- Un GameObject puede estar dentro de otro
GameFieldagrupaMiddleLine,TopWallyBottomWall- El hijo se mueve, gira y escala con el padre
- Su Position es relativa al padre
- Por eso
GameFielddebe estar en (0, 0, 0)
- Por eso
En la Jerarquía, un GameObject se puede arrastrar dentro de otro. El de dentro
pasa a ser hijo del de fuera, que es su padre. Lo hicimos con
GameField, un objeto vacío que agrupa la línea central y los dos muros.
Además de ordenar la Jerarquía, esto tiene consecuencias: cuando el padre se
mueve, gira o cambia de escala, los hijos lo acompañan. Y la Position que
muestra el Inspector para un hijo es relativa a su padre, no a la escena. Por
eso conviene que GameField esté en (0, 0, 0): si estuviera desplazado, los
muros también lo estarían, aunque sus valores en el Inspector fueran los
correctos.
Scripts
- Un script es una clase que hereda de
MonoBehaviour- El fichero y la clase se llaman igual
- Es un componente más: se arrastra al GameObject
- Variables públicas → se rellenan en el Inspector
- Métodos que llama Unity, no nosotros:
Start: una vez, al empezarUpdate: en cada fotogramaOnTriggerEnter2D: cuando algo entra en el trigger
Cuando lo que necesitamos no lo da ningún componente de Unity, lo programamos
en un script: una clase de C# que hereda de MonoBehaviour (por eso
aparece : MonoBehaviour tras el nombre de la clase). El nombre del fichero y
el de la clase deben coincidir; por eso hay que escribir el nombre nada más
crear el script.
Un script es un componente más: se añade a un GameObject arrastrándolo, y
cada GameObject que lo lleve tiene su propia copia, con sus propios valores.
Sus variables públicas aparecen en el Inspector, de modo que se pueden
cambiar sin tocar el código. Así, las dos palas usan el mismo script Paddle
con teclas distintas: basta con marcar isPlayer1 en una de ellas.
Lo más llamativo es que nunca escribimos una llamada a Start ni a Update.
Son métodos con nombres especiales que Unity llama automáticamente:
Start, una vez, al empezar el juego (antes del primer fotograma).Update, en cada fotograma, decenas de veces por segundo. Ahí leemos el teclado.OnTriggerEnter2D, cuando otro collider entra en un trigger del objeto.

Leer el teclado
- Paquete Input System:
using UnityEngine.InputSystem; Keyboard.current[Key.W].isPressed:truemientras se pulsa la W- Variable pública
isPlayer1: W/S o flechas
if (isPlayer1)
{
if (Keyboard.current[Key.W].isPressed)
{
movement += 1;
}
// Igual con Key.S, restando 1
}
else
{
// Igual con Key.UpArrow y Key.DownArrow
}
Para leer el teclado, el ratón o los mandos usamos el paquete Input
System de Unity. Para usarlo, el script empieza con
using UnityEngine.InputSystem;.
Keyboard.current es el teclado conectado, y entre corchetes se indica la
tecla con el tipo Key: Keyboard.current[Key.W].isPressed vale true
mientras la tecla W está pulsada. Las teclas se identifican por su
posición en el teclado, no por la letra que tienen impresa: Key.W es la
tecla que en un teclado español lleva la W.
Las dos palas usan el mismo script, pero cada una se mueve con sus teclas.
Para distinguirlas, el script tiene una variable pública isPlayer1 de tipo
bool, que marcamos en el Inspector solo en Player1. En Update, un if
comprueba esa variable: si vale true, se leen W y S; si no, las
flechas ↑ (Key.UpArrow) y ↓ (Key.DownArrow).
En los dos casos, movement empieza en 0, sumamos 1 si se pulsa la tecla de
subir y restamos 1 si se pulsa la de bajar. Si se pulsan las dos, se anulan y
la pala se queda quieta.
Mover con física
- No cambiamos la posición: damos velocidad al Rigidbody 2D
- La física la mueve y la para al chocar con los muros
Vector2: un valor para X y otro para Y- Unidades por segundo
- En Unity 6 es
linearVelocity(antes,velocity)
rb.linearVelocity = new Vector2(rb.linearVelocity.x, movement * speed);
Podríamos mover las palas cambiando su posición en el Transform, pero el objeto tiene un Rigidbody 2D y, cuando la física controla un objeto, no hay que moverlo por otro camino: el motor de física y nuestro código se pisarían, y la pala podría atravesar los muros. Lo correcto es decirle al Rigidbody 2D a qué velocidad debe ir y dejar que la física haga el resto, incluido pararla al chocar con un muro.
La velocidad es la propiedad linearVelocity, un Vector2: un valor para la X
y otro para la Y, en unidades por segundo. En la pala mantenemos la X que ya
tenía (rb.linearVelocity.x) y cambiamos solo la Y. Si movement vale 1 y
speed vale 5, la pala sube a 5 unidades por segundo.
En tutoriales de versiones anteriores a Unity 6 verás rb.velocity: es la
misma propiedad con su nombre antiguo.
Lanzar la bola
Random.Range(0, 2)con enteros: 0 o 1 (el máximo se excluye)- Con
float, el máximo sí se incluye
- Con
- Operador ternario:
condición ? siSí : siNo - Una dirección al azar para X y otra para Y: cuatro diagonales
Startllama aLaunchUnityEngine.Random: la de Unity, no la de .NET
float x = UnityEngine.Random.Range(0, 2) == 0 ? -1 : 1;
La bola se lanza en Start, que llama a nuestro método Launch. Para que no
salga siempre hacia el mismo lado, elegimos al azar la dirección en cada eje.
Random.Range devuelve un número aleatorio entre dos valores, pero se comporta
distinto según el tipo. Con enteros, el máximo queda excluido:
Random.Range(0, 2) solo puede devolver 0 o 1. Con decimales (float), el
máximo se incluye: Random.Range(0f, 2f) puede devolver cualquier valor
entre 0 y 2, ambos incluidos.
Con el operador ternario que vimos en C# convertimos ese 0 o 1 en −1 o 1. Haciendo lo mismo para la Y, la bola sale en una de las cuatro diagonales.
¿Por qué UnityEngine.Random?
Hay dos clases que se llaman Random: la de Unity y la de .NET. Si el
script tiene también using System; (a veces el editor lo añade solo), el
compilador no sabe cuál usar y da un error. Escribiendo el nombre completo,
UnityEngine.Random, nunca hay duda.
Material físico
- Physics Material 2D: cómo se comporta una superficie
- Friction = 0: no frena al rozar
- Bounciness = 1: rebota sin perder energía
- Se asigna al Rigidbody 2D de la bola
Con la bola ya programada, toca afinar la física. Sin más ajustes, la bola pierde velocidad en cada choque. Un Physics Material 2D describe cómo se comporta una superficie al chocar:
- Friction es el rozamiento: cuanto mayor, más frena al deslizar contra otra superficie. Lo pusimos a 0.
- Bounciness es la capacidad de rebotar: con 0 no rebota y con 1 rebota sin perder energía.
Creamos el material como un asset (en la carpeta Materials) y lo
arrastramos a la propiedad Material del Rigidbody 2D de la bola.

Constraints
- En el Rigidbody 2D: bloquean movimientos
- Bola: Freeze Rotation Z (no gira al chocar)
- Palas: también Freeze Position X (solo suben y bajan)
La sección Constraints del Rigidbody 2D bloquea movimientos concretos, aunque la física intente producirlos:
- A la bola le marcamos Freeze Rotation Z: no debe girar al chocar.
- A las palas, además, Freeze Position X: solo deben moverse arriba y abajo, aunque la bola las empuje hacia los lados al chocar.

Detectar los puntos
OnTriggerEnter2D(Collider2D collision)collision: el collider que ha entrado
- Etiqueta
BallyCompareTag("Ball"): ¿es la bola? - Un solo script
Goalpara las dos metasisPlayer1Wallmarcado solo enPlayer1Goal- Entra en la meta del jugador 1 → punto del jugador 2
- De momento,
Debug.Log: el punto sale en la Consola
if (collision.gameObject.CompareTag("Ball"))
Como las metas son triggers, Unity llama al método OnTriggerEnter2D de sus
scripts cada vez que otro collider entra en ellas. El parámetro collision es
el collider que ha entrado. Si los colliders no fueran triggers y rebotaran,
el método que se llamaría sería otro, OnCollisionEnter2D.
Antes de anotar un punto hay que comprobar que lo que ha entrado es la bola, y
no, por ejemplo, una pala. Para eso creamos la etiqueta Ball, se la pusimos a
la bola y la comprobamos con CompareTag("Ball").
Las dos metas usan el mismo script Goal. Lo que las distingue es la variable
pública isPlayer1Wall, marcada solo en la meta del jugador 1. Ojo con el
razonamiento: si la bola entra en la meta del jugador 1, el punto es para el
jugador 2. Por eso, si isPlayer1Wall vale true, el punto es del jugador 2, y si
no (la meta es la del jugador 2), del jugador 1.
De momento, la meta no hace nada más que escribir en la Consola quién ha
marcado, con Debug.Log. Así comprobamos que los puntos se detectan y se
atribuyen al jugador correcto antes de seguir.
Volver a la posición inicial
- Tras un punto, la bola y las palas vuelven a su sitio
startPosition: se guarda enStartResetPosition(): para el objeto y lo devuelve a su sitio- La bola, además, se vuelve a lanzar
- No llamarlo
Reset: Unity ya usa ese nombre
public void ResetPosition()
{
rb.linearVelocity = Vector2.zero;
transform.position = startPosition;
}
Después de un punto, la jugada tiene que volver a empezar. Lo preparamos en cada
objeto: la bola y las palas guardan su posición inicial en una variable
privada, startPosition, en cuanto empieza el juego (en Start). Y tienen un
método público, ResetPosition, que las para (velocidad a cero) y las devuelve
a esa posición. En la bola, además, se llama a Launch() para volver a
lanzarla.
Así, cada objeto sabe volver a su sitio y nadie más necesita saber dónde estaba.
ResetPosition, no Reset
Reset es un nombre que Unity usa para devolver un componente a sus
valores por defecto: el editor lo llama automáticamente, por ejemplo al
añadir el componente, aunque el juego no esté en marcha.
El GameManager
- ¿Quién suma el punto y reinicia la jugada?
- La meta no debería conocer la bola ni las palas
- Ni llevar cada meta su propio marcador
- GameManager: GameObject vacío con un script que coordina el juego
- Lleva el marcador
- La meta le avisa → suma el punto y reinicia bola y palas
Ya detectamos los puntos y cada objeto sabe volver a su sitio. Falta alguien que lo junte todo. Podría hacerlo cada meta, pero entonces cada una tendría que conocer la bola y las dos palas, y llevar su propio marcador: el marcador quedaría repartido en dos sitios. Hay tareas que no son de ningún objeto en concreto, como llevar el marcador o reiniciar la jugada. Para eso se suele crear un GameManager: un GameObject vacío con un script que coordina a los demás, como un director de orquesta.
El reparto de tareas queda así:
- La meta detecta que ha entrado la bola y avisa al GameManager con
Player1Scores()oPlayer2Scores(). - El GameManager suma el punto, muestra el marcador en la Consola y llama a
ResetPositions(). ResetPositions()pide a la bola y a cada pala que vuelvan a su sitio con su métodoResetPosition().
Comunicación entre scripts
- Variable pública con el tipo de otro componente
public Rigidbody2D rb;public Ball ball;
- Se rellena arrastrando en el Inspector
- Con la referencia, se usan sus métodos públicos
ball.ResetPosition();
- En las metas:
Debug.Log→gameManager.Player1Scores(); - Vacía (None) →
NullReferenceException
Un script casi nunca trabaja solo: la pala necesita su Rigidbody 2D, y el
GameManager necesita la bola y las palas. Para eso, el script declara una
variable pública cuyo tipo es el del componente que necesita. Puede ser un
componente de Unity (public Rigidbody2D rb;) o uno de nuestros scripts
(public Ball ball;), porque cada script es una clase y, por tanto, un tipo.
Esa variable se rellena en el Inspector arrastrando hasta su hueco el
GameObject que tiene ese componente. A partir de ahí, el script puede leer sus
propiedades y llamar a sus métodos públicos, como ball.ResetPosition().
Es lo que hicimos para terminar: el GameManager recibe la bola y las dos
palas, y las metas reciben el GameManager (public GameManager gameManager;).
Con esa referencia, sustituimos en Goal los Debug.Log por llamadas a
gameManager.Player2Scores() y gameManager.Player1Scores().
Si se nos olvida arrastrarlo, la variable queda vacía (el Inspector muestra
None) y, cuando el código intente usarla, la Consola mostrará un error
NullReferenceException. Es uno de los errores más frecuentes en Unity.
Hay otras formas de conseguir una referencia desde el código, como
GetComponent<Rigidbody2D>(), que busca un componente de ese tipo en el mismo
GameObject. En PongExpress usamos el Inspector, que es lo más sencillo y
visual.
Versión 2: luces 2D
- Plantilla Universal 2D: renderizador 2D de URP, con luces 2D
- Global Light 2D ilumina todo por igual
- Si se desactiva, la escena queda a oscuras
- Luces como hijos de cada objeto:
- Spot Light 2D
- Freeform Light 2D, con la forma que le demos
La plantilla Universal 2D usa el renderizador 2D de URP, que permite iluminar la escena con luces 2D. La escena viene con una luz, Global Light 2D, que ilumina todo por igual; por eso al principio no se nota.
Si se desactiva, la escena queda a oscuras, y entonces podemos añadir luces como hijas de cada objeto que queramos iluminar: una Spot Light 2D, o una Freeform Light 2D, cuya forma podemos ajustar a la del objeto. Como son hijas, se mueven con él. Ajustando el color, la intensidad y el radio de cada luz, el mismo juego cambia por completo de aspecto sin tocar el código.
Versión 3: estela de la bola
- Trail Renderer: dibuja una estela detrás de un objeto que se mueve
- Se añade como componente a la bola
- Material Sprite-Unlit-Default: trazo continuo
- Width: de 0,25 a 0, se afina
- Color: el de la luz de la bola, de opaco a transparente
- Bola con Order in Layer 1, delante de la estela
- Time: cuánto tarda en desaparecer cada tramo
El último toque fue una estela detrás de la bola. La dibuja el componente Trail Renderer, que va dejando un rastro por donde pasa el objeto que lo lleva. Se lo añadimos a la bola y ajustamos su aspecto en el Inspector:
- Material, el que se usa para dibujarla. Elegimos
Sprite-Unlit-Default, que trae URP: no tiene textura, así que la estela es un trazo continuo, y no le afectan las luces 2D, así que se ve aunque la luz global esté apagada. - Width es el grosor de la estela, y se define con una curva a lo largo de ella. Haciéndolo bajar de 0,25 junto a la bola a 0 al final, la estela se afina hasta desaparecer.
- Color es un degradado a lo largo de la estela. Pusimos el color de la luz de la bola y, en el canal alfa (la opacidad), 255 junto a la bola y 0 al final: la estela se va volviendo transparente.
- Time, que no tocamos, indica cuántos segundos tarda en desaparecer cada tramo: cuanto mayor, más larga es la estela.
Al principio la estela se pintaba encima de la bola. Para que la bola quede delante, le pusimos Order in Layer a 1 en su Sprite Renderer (la estela tiene 0).
Como en la versión anterior, todo esto se consigue sin escribir código.
Preguntas de repaso
¿Por qué los muros no llevan Rigidbody 2D?
No se mueven. Un collider sin Rigidbody 2D es estático, y la bola, que sí lo tiene, choca con él.
¿Qué pasa si no marcamos Is Trigger en las metas?
La bola rebota en ellas como en un muro y no se llama a OnTriggerEnter2D: no se cuenta el punto.
Preguntas de repaso (II)
¿Qué valores puede devolver Random.Range(0, 2)? ¿Y Random.Range(0f, 2f)?
El primero, 0 o 1 (con enteros, el máximo se excluye). El segundo, cualquier valor entre 0 y 2, ambos incluidos.
¿Por qué movemos las palas con linearVelocity y no cambiando su posición?
Porque las mueve el motor de física: así se paran al chocar con los muros.
Al marcar un punto aparece NullReferenceException. ¿Qué miramos primero?
Las variables públicas de las metas y del GameManager en el Inspector: alguna se ha quedado en None.