Driving AI: racing line, rubber-banding y nets neuronales
Cómo Mario Kart te alcanza siempre, cómo Forza calcula la línea óptima, y por qué TrackMania resolvió todo entrenando una red neuronal sobre tu carrera fantasma.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Un coche no es un agente puntual. Tiene inercia, neumáticos que derrapan, un motor que tarda en responder y un volante con tope. Si reutilizas el seek de Steering Behaviors I tal cual, el coche oscila, se sale en cada curva y nunca encuentra el ritmo de una carrera real.
Y encima hay un problema extra: en racing, un agente perfecto no sirve. Si la AI siempre te gana, frustra. Si siempre la ganas, aburre. Necesitas una pieza más: la AI tiene que competir contigo, no contra el cronómetro.
Cada juego de carreras famoso resuelve esto a su manera:
- Mario Kart — rubber-banding agresivo: la AI acelera cuando estás delante, frena cuando estás detrás. Más items para los rezagados.
- Forza / Gran Turismo — racing line precomputada y trazada por humanos profesionales en cada circuito.
- TrackMania (Linesight) — una red neuronal aprende a pilotar con self-play y vence récords del mundo.
- GTA / Watch Dogs — splines de carriles + behaviors urbanos. Sin línea óptima ni rubber-band; es tráfico, no carrera.
1. Demo
2. Concepto
Driving AI mezcla cuatro problemas distintos: seguir una línea óptima (racing line), controlar el vehículo con su física (steering + throttle), competir contra el jugador con dificultad ajustable (rubber-banding), y resolver tráfico y colisiones. Cada uno tiene su técnica, y casi nadie los resuelve con un solo algoritmo.
A diferencia de un NPC a pie, el coche no puede decidir girar 90 grados en un frame. Tiene que pedirle al volante y al acelerador, y el resto lo decide la física. Eso cambia toda la estrategia.
2.1 Racing line: la curva más rápida
La racing line es la trayectoria que minimiza el tiempo de vuelta, no la distancia. La regla clásica es out-in-out: entras por el exterior de la curva, tocas el apex (el punto más interno) y sales otra vez por el exterior.
Entrada por el exterior, apex en el interior, salida por el exterior. Un punto recorre la línea óptima.
¿Por qué funciona? Porque maximiza el radio de la curva tomada. A más radio, más velocidad antes de perder agarre. La línea recta entre tres puntos no curva — la curva más amplia posible sí.
En la práctica la racing line vive como:
- Una polilínea de waypoints densos con datos extra (curvatura, velocidad objetivo) precomputados.
- Un spline (Bezier o Catmull-Rom) sobre el centro del trazado, que el motor evalúa con un parámetro
t ∈ [0,1].
Forza y GT la trazan con telemetría real de pilotos. Mario Kart la dibuja a mano en el editor. Los dos son válidos.
2.2 ¿Cómo sigue el coche la línea?
No apuntas al punto más cercano de la línea. Apuntas a un look-ahead point: un punto N metros adelante sobre la línea. Es el truco de pure pursuit.
El coche apunta a un punto N metros adelante sobre la línea, no al más cercano.
Por qué importa el look-ahead:
- Look-ahead corto → el coche pega la línea con precisión pero oscila: cada micro-error se corrige al instante y el volante tiembla.
- Look-ahead largo → curvas suaves, sin oscilación, pero corta apex y se sale por fuera en curvas cerradas.
Una regla práctica: lookAhead = max(2m, speed * 0.3s). Crece con la velocidad, igual que tu mirada cuando conduces de verdad.
2.3 Control de steering y throttle
Entre “quiero ir a este punto” y “el volante gira esta cantidad” hay un controlador. El estándar de la industria es un PID — Proportional-Integral-Derivative — sobre el ángulo de error entre el forward del coche y el vector al look-ahead.
El error se reparte por tres ramas (P, I, D) que se suman para dar la salida final de steering.
- P (proportional) — empuja proporcional al error. Tira fuerte.
- I (integral) — acumula error en el tiempo. Corrige sesgos persistentes (ej. coche con neumáticos desbalanceados). En la mayoría de juegos arcade,
Ki = 0y vive feliz. - D (derivative) — frena cuando el error baja rápido. Es lo que evita la oscilación.
Para throttle el patrón es parecido pero modulado por la curvatura del tramo que tienes delante:
targetSpeed = maxSpeed * (1 - curvature * k)
throttle = pidThrottle.compute(currentSpeed, targetSpeed, dt)
En recta, curvature ≈ 0 y aceleras a tope. En horquilla, curvature es alta, targetSpeed baja y el PID activa el freno antes de entrar.
2.4 ¿Qué es rubber-banding y cuándo se vuelve injusto?
Rubber-banding es ajustar las stats de la AI según su distancia al jugador. El nombre viene de “goma elástica”: el grupo nunca se estira demasiado.
Cuando la AI está atrás del jugador, su top-speed sube; cuando está delante, baja. En el punto base, top-speed sin ajuste.
Dos sabores:
- Sutil (sim racing decente) — ±3-5% en top-speed, ±5% en aceleración. Suficiente para que el pelotón no se rompa, invisible para el jugador.
- Agresivo (Mario Kart) — +20% velocidad para el último, items potentes solo para rezagados (azul, relámpago), peor RNG para el líder. Es escandaloso pero divertido porque el juego ya es caótico.
La línea roja es cuando el jugador detecta el truco. Si frenas a propósito y la AI no te adelanta, rompiste la inmersión. Trucos para esconderlo:
- Solo aplicar rubber-band cuando no estés mirando a la AI específica.
- Modular
maxSpeed, no la velocidad actual (cambiar la velocidad de golpe se nota; un top más alto solo se nota tras varios segundos). - Añadir error simulado al pilotaje de la AI (frenadas tardías, salidas anchas) en niveles fáciles, para que ganar se sienta merecido.
2.5 Tráfico urbano vs racing
Una confusión común: los coches de GTA, Watch Dogs o Cyberpunk no usan racing line ni PID. Usan:
- Splines de carriles — el mapa tiene polilíneas marcando carriles y direcciones permitidas.
- Behaviors discretos — seguir carril, cambiar carril, frenar en semáforo, evitar al peatón. Una mini-FSM por coche.
- Sin rubber-banding — no compiten contigo, son decorado dinámico.
Si lo que estás haciendo es tráfico de ciudad, salta este tutorial entero y mira path following y FSM. Si lo que haces es carrera, sigue leyendo.
2.6 ¿Cuándo redes neuronales tienen sentido?
Las soluciones clásicas (racing line + PID + rubber-band) cubren el 95% de los juegos de carreras comerciales. Las redes neuronales entran cuando:
- El circuito no tiene racing line precomputada y quieres que la AI lo descubra (TrackMania genera trazados infinitos: precomputar manualmente no escala).
- Quieres una AI que encuentre trazados súper-humanos, no solo replicar uno bueno (Linesight de TrackMania, Sophy de Gran Turismo).
- Tienes un físico tan complejo que afinar PIDs a mano es una pesadilla.
Inputs típicos de la red:
- Raycasts alrededor del morro (8-32 sensores) — distancia al borde del circuito.
- Velocidad y derrape lateral del coche.
- Ángulo al siguiente waypoint o al goal.
Outputs:
- Steering continuo en
[-1, 1]. - Throttle/brake en
[-1, 1](combinado o separado).
Métodos de entrenamiento:
- Imitation learning — grabas tus propias vueltas y la red aprende a imitarte. Más barato, queda capado por tu skill.
- Reinforcement learning (PPO, SAC) — la red explora y se premia con tiempo de vuelta. Tarda más, supera al humano si la dejas.
- Self-play — varias copias compiten entre sí y se quedan las que ganan. Linesight usa esto sobre ghost replays.
Unity ML-Agents trae el Karting Microgame como ejemplo oficial y es buen punto de partida si quieres jugar con esto. No lo metas en producción a la ligera: inferencia en runtime cuesta, debug es duro y los reentrenos por cambios de balance son carísimos.
2.7 Collision avoidance entre coches
Dos coches en la misma racing line van a chocar tarde o temprano. El truco no es “esquivar siempre”, es decidir si adelantar, esperar o frenar:
- Look-ahead lateral — proyecta el bounding box del coche N metros adelante. Si se solapa con otro coche, hay conflicto.
- Si hay sitio para adelantar (carril libre por fuera) → desvía la racing line lateralmente unos metros, manda al PID de steering.
- Si no hay sitio → frena suavemente y mantiene la línea (ver el unaligned collision avoidance de Steering Behaviors II para la matemática del cruce).
Los dos modos fallidos clásicos:
- Demasiado defensivo — la AI nunca adelanta, se queda detrás del jugador toda la carrera.
- Demasiado agresivo — la AI intenta adelantar en horquilla, te golpea y hace spin-out (suya o tuya).
Burnout y Need for Speed lo resuelven con un toque de scripting: dos AIs designadas como “rivales del jugador” tienen permiso para acercarse mucho; el resto del pelotón guarda más distancia.
2.8 ¿Cómo encajan dificultad y skill?
Combinar todas las piezas con perillas distintas por nivel:
| Parámetro | Fácil | Normal | Difícil |
|---|---|---|---|
| Racing line | Con jitter aleatorio (±0.5m) | Limpia | Limpia, frenada tardía |
| Throttle target | 85% de curva ideal | 100% | 105% (al límite) |
| Rubber-band | Agresivo (±10%) | Sutil (±3%) | Apagado |
| Mistakes simulados | 1 cada 30s | 1 cada 2min | Nunca |
| Collision avoidance | Muy defensiva | Equilibrada | Agresiva |
Un solo motor de driving sirve para los tres si expones esos parámetros. No hagas tres clases de AI distintas.
3. Pseudocódigo
function driveTick(car: Car, line: RacingLine, dt: Float)
target = lookAheadPoint(line, car.position, car.lookAheadDist)
toAim = target - car.position
angleError = signedAngle(car.forward, toAim)
# Steering: PID sobre el ángulo de error
steerOutput = pidSteer.compute(angleError, dt)
steerOutput = clamp(steerOutput, -1, 1)
# Throttle: PID sobre la diferencia con la velocidad ideal del tramo
curvature = lineCurvatureAt(line, target)
targetSpeed = car.maxSpeed * (1 - curvature * curvatureBrakeK)
throttleOutput = pidThrottle.compute(car.speed, targetSpeed, dt)
throttleOutput = clamp(throttleOutput, -1, 1)
car.applySteering(steerOutput)
car.applyThrottle(throttleOutput)
function lookAheadPoint(line: RacingLine, pos: Vec3, baseDist: Float) -> Vec3
closest_t = line.closestParam(pos)
look_t = closest_t + baseDist / line.length
return line.evaluate(look_t)
function rubberBandTopSpeed(baseSpeed, distToPlayer, sign, maxBand, factorMax) -> Float
# sign = +1 si AI atrás del player, -1 si delante
factor = clamp(distToPlayer / maxBand, 0, 1)
return baseSpeed * (1 + sign * factor * factorMax)
function pidCompute(state, error, dt) -> Float
state.integral += error * dt
derivative = (error - state.lastError) / max(dt, epsilon)
state.lastError = error
return state.Kp * error + state.Ki * state.integral + state.Kd * derivative
El patrón: cada tick calcula un objetivo (look-ahead, targetSpeed), un error frente al estado actual, y un PID que traduce ese error en una orden continua al vehículo. La física hace el resto.
4. Implementación en Unity / C#
using UnityEngine;
public class CarAI : MonoBehaviour {
public RacingLine racingLine;
public Rigidbody rb;
[Header("Look-ahead")]
public float baseLookAhead = 4f;
public float lookAheadSpeedFactor = 0.3f;
[Header("Speed targets")]
public float maxSpeed = 45f;
public float curvatureBrakeK = 2.5f;
[Header("PID steering")]
public float kpSteer = 1.2f, kiSteer = 0f, kdSteer = 0.18f;
[Header("PID throttle")]
public float kpThr = 0.8f, kiThr = 0.02f, kdThr = 0.05f;
[Header("Rubber-banding")]
public Transform player;
public float rubberMaxBand = 60f;
public float rubberFactorMax = 0.05f;
PidState steerPid = new(), throttlePid = new();
void FixedUpdate() {
if (racingLine == null) return;
float dt = Time.fixedDeltaTime;
// 1) Look-ahead point sobre la racing line
float lookAhead = baseLookAhead + rb.linearVelocity.magnitude * lookAheadSpeedFactor;
Vector3 target = racingLine.LookAheadFrom(transform.position, lookAhead);
// 2) Error angular hacia el look-ahead
Vector3 toAim = target - transform.position;
toAim.y = 0f;
float angleError = Vector3.SignedAngle(transform.forward, toAim, Vector3.up);
// 3) PID de steering → [-1, 1]
float steer = Mathf.Clamp(steerPid.Step(angleError, dt, kpSteer, kiSteer, kdSteer) / 60f, -1f, 1f);
// 4) Velocidad objetivo según curvatura del tramo
float curvature = racingLine.CurvatureAt(target);
float baseTopSpeed = maxSpeed;
if (player != null) {
float dist = Vector3.Distance(player.position, transform.position);
float sign = AmIBehindPlayer() ? 1f : -1f;
baseTopSpeed *= 1f + sign * Mathf.Clamp01(dist / rubberMaxBand) * rubberFactorMax;
}
float targetSpeed = baseTopSpeed * Mathf.Clamp01(1f - curvature * curvatureBrakeK);
// 5) PID de throttle (negativo = freno)
float speed = Vector3.Dot(rb.linearVelocity, transform.forward);
float throttle = Mathf.Clamp(throttlePid.Step(targetSpeed - speed, dt, kpThr, kiThr, kdThr), -1f, 1f);
ApplyControls(steer, throttle);
}
void ApplyControls(float steer, float throttle) {
// Simulación arcade: torque directo. Para sim real, usa WheelColliders.
rb.AddTorque(transform.up * steer * 800f * Time.fixedDeltaTime, ForceMode.VelocityChange);
if (throttle >= 0f) rb.AddForce(transform.forward * throttle * 1200f * Time.fixedDeltaTime, ForceMode.Acceleration);
else rb.AddForce(-rb.linearVelocity.normalized * (-throttle) * 900f * Time.fixedDeltaTime, ForceMode.Acceleration);
}
bool AmIBehindPlayer() {
return racingLine.ProgressOf(transform.position) < racingLine.ProgressOf(player.position);
}
}
public class PidState {
float integral, lastError;
public float Step(float error, float dt, float kp, float ki, float kd) {
integral += error * dt;
float d = (error - lastError) / Mathf.Max(dt, 1e-4f);
lastError = error;
return kp * error + ki * integral + kd * d;
}
}
5. En otros engines
- Godot:
VehicleBody3Dresuelve la física de ruedas; el PID y la racing line viven en un script aparte.Path3D+PathFollow3Dte da el look-ahead point gratis si conviertes la racing line aCurve3D. - Unreal:
WheeledVehicleMovementComponent(Chaos Vehicles) maneja la simulación. El driving AI vive en unAAIControllerque escribe enMovementInputcada tick. Para racing line, unUSplineComponenteditado en escena cubre el caso. - JavaScript / arcade 2D: la matemática es idéntica. Sustituye el
Rigidbodypor integración explícita (vel += accel * dt; pos += vel * dt), añade un coeficiente de derrape lateral y tienes un top-down arcade en 200 líneas.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Tu coche AI oscila a izquierda y derecha siguiendo una recta. ¿Qué parámetro ajustas primero?
Tu rubber-banding es tan obvio que los jugadores lo notan en los foros. ¿Cómo lo escondes mejor?
¿Cuándo una red neuronal supera a un PID + racing line precomputada?
Tu AI frena demasiado tarde y se sale en cada horquilla. ¿Qué cambias primero?