Fran León

Gameplay Programmer

Unreal Engine 5.8 · C++ / Blueprints · IA, replicación y física

Unreal Engine 5.8 · C++ / Blueprints · AI, replication and physics

Proyecto 01

Project 01

NightmareLoop

Coop de terror en Unreal Engine 5.8.

Co-op horror in Unreal Engine 5.8.

Vertical slice de sistemas · demo técnica, no un juego terminado

Systems vertical slice · technical demo, not a finished game

Alcance. Es una vertical slice: los sistemas funcionan, el juego no está terminado. El arte y las mallas son packs de marketplace. Hay un segundo contribuidor con perfil de nivel y arte; ninguno de los sistemas de IA, inventario o red es suyo.
Scope. This is a vertical slice: the systems work, the game is not finished. Art and meshes are marketplace packs. There is a second contributor doing level design and art; none of the AI, inventory or networking systems are theirs.

1.1Percepción graduada por jugador

1.1Per-player graded perception

AIPerception · C++ · Behavior Tree

Problema

AIPerception es binario: te ve o no te ve, y cambia de estado en un frame. En terror eso produce dos fallos que se sienten mal: detección instantánea al cruzar el borde del cono, y pérdida igual de rápida tras una esquina. En coop de 4 hay un tercero: con un solo «objetivo actual», el enemigo oscila entre dos jugadores equidistantes y no persigue a ninguno.

Problem

AIPerception is binary: it sees you or it doesn't, and it flips state in one frame. In horror that produces two failures that feel wrong: instant detection when you cross the edge of the cone, and equally instant loss around a corner. In 4-player co-op there is a third: with a single «current target», the enemy oscillates between two equidistant players and chases neither.

Qué decidí

Un medidor continuo 0→1 por jugador, almacenado en el AIController y no en el pawn: el conocimiento del enemigo no es propiedad de su cuerpo. Sube con Gain × DistFactor × PostureFactor × Δt. DistFactor interpola de 1.0 a 0.25 entre 500 y 1500 unidades; PostureFactor va de 0.35 agachado a 1.5 corriendo. El umbral 0.5 escribe la posición a investigar; 1.0 escribe el objetivo.

What I decided

A continuous 0→1 meter per player, stored on the AIController and not on the pawn: the enemy's knowledge is not a property of its body. It rises with Gain × DistFactor × PostureFactor × Δt. DistFactor interpolates from 1.0 to 0.25 between 500 and 1500 units; PostureFactor goes from 0.35 crouched to 1.5 sprinting. Threshold 0.5 writes the position to investigate; 1.0 writes the target.

Por qué gana a lo obvio

La solución ingenua es un temporizador — «si te veo N segundos, te detecto» — que no distingue estar a 15 metros agachado de estar a 3 metros corriendo. Aquí sale una curva que el jugador puede leer y contra la que puede jugar.

Why it beats the obvious

The naive solution is a timer — «if I see you for N seconds, I detect you» — which doesn't tell being 15 metres away crouched from 3 metres away sprinting. This gives a curve the player can read and play against.

Dos detalles que solo aparecen jugando

  • Hold de 0.75 s antes de decaer, para que romper la línea de visión un frame no devuelva el medidor a cero.
  • Sticky bonus de 0.15 al objetivo ya elegido, usado solo para comparar candidatos y no para los umbrales. Mata la oscilación entre jugadores sin hacer al enemigo más agresivo.

Two details that only show up in play

  • A 0.75 s hold before decay, so breaking line of sight for one frame doesn't drop the meter back to zero.
  • A 0.15 sticky bonus for the already-chosen target, used only to compare candidates and not for the thresholds. It kills the oscillation between players without making the enemy more aggressive.

Integración

El sistema entró sin refactorizar nada aguas abajo: su única salida son claves de blackboard que ya existían.

Integration

The system went in without refactoring anything downstream: its only output is blackboard keys that already existed.

Por qué hay C++ aquí

SensesConfig es protected + EditDefaultsOnly, y GetSenseConfig / ConfigureSense no son UFUNCTION. Sin un wrapper en C++ (UBlueprintFunctionLibrary), el DataAsset no puede gobernar radios ni ángulo de visión en runtime.

Why C++ here

SensesConfig is protected + EditDefaultsOnly, and GetSenseConfig / ConfigureSense are not UFUNCTION. Without a C++ wrapper (UBlueprintFunctionLibrary), the DataAsset cannot drive sight radii or cone angle at runtime.

1.2Escondites con memoria

1.2Hiding spots with memory

Behavior Tree · blackboard · navegación

Behavior Tree · blackboard · navigation

Problema

En un juego de esconderse, la taquilla es o inútil o invencible, y las dos son malas. Hace falta que el enemigo abra la taquilla correcta por la razón correcta: la que te vio usar.

Problem

In a hiding game, the locker is either useless or unbeatable, and both are bad. What is needed is for the enemy to open the right locker for the right reason: the one it saw you use.

Qué decidí

El escondite no le dice a la IA «estoy ocupado», le dice «este jugador acaba de meterse aquí», y es la IA quien decide si eso le consta. El gate es (tiempo desde que lo vio ≤ 0.5 s) && (sospecha ≥ umbral), reutilizando el sello de tiempo del sistema de percepción para distinguir «te vi entrar» de «te metiste cuando no miraba». Un jugador que no está en el mapa de percepción devuelve 0.0 y el gate se cierra solo: el comportamiento por defecto sale gratis.

What I decided

The hiding spot doesn't tell the AI «I'm occupied», it tells it «this player just got in here», and the AI decides whether it knows that. The gate is (time since it saw them ≤ 0.5 s) && (suspicion ≥ threshold), reusing the timestamp from the perception system to tell «I saw you go in» from «you got in while I wasn't looking». A player not in the perception map returns 0.0 and the gate closes by itself: the default behaviour comes for free.

Por qué gana a lo obvio

«Si el jugador está escondido, ve a esa taquilla» es un aimbot. «Revisa taquillas al azar» es ruido. Aquí hay tres piezas combinadas:

  • Elección por coste de path, no distancia recta: una taquilla a 5 m al otro lado de una pared no es la más cercana.
  • Lista negra de taquillas ya revisadas, que se limpia entera en cuanto vuelve a verte, porque a partir de ahí la información vieja no vale.
  • Rama aparte de curiosidad ociosa que abre taquillas al azar de vez en cuando: así abrir una taquilla no es información fiable para el jugador.

Why it beats the obvious

«If the player is hidden, go to that locker» is an aimbot. «Check random lockers» is noise. Three pieces combined here:

  • Choice by path cost, not straight-line distance: a locker 5 m away on the other side of a wall is not the closest one.
  • A blacklist of already-checked lockers, wiped entirely as soon as it sees you again, because from that point old information is worthless.
  • A separate idle-curiosity branch that opens random lockers now and then: so opening a locker is not reliable information for the player.

Control de prioridad

Con decoradores del árbol y observer aborts puestos a mano rama por rama, no con banderas.

Priority control

With tree decorators and observer aborts set by hand branch by branch, not with flags.

1.3Equipamiento en red

1.3Networked equipping

Replicación de Unreal · RPCs · animation notifies

Unreal replication · RPCs · animation notifies

Problema

Equipar con animación deja de ser una asignación de variable y pasa a ser una transición con duración. Dos frentes a la vez: la entrada del jugador es más rápida que la animación (tres giros de rueda en medio segundo), y en coop el resultado tiene que ser el mismo en cuatro máquinas, incluida la mano del compañero, que no tiene predicción local.

Problem

Equipping with animation stops being a variable assignment and becomes a transition with duration. Two fronts at once: player input is faster than the animation (three wheel scrolls in half a second), and in co-op the result has to be the same on four machines, including the teammate's hand, which has no local prediction.

Qué decidí

Máquina de estados de tres fases que serializa equipar y desequipar. Si hay algo en la mano, se guarda la intención, se lanza el montage de desequipar y el nuevo objeto entra cuando ese termina. El objeto se spawnea oculto y lo revela un notify a mitad de la animación: nunca hay un frame con el objeto flotando en la pose de reposo.

What I decided

A three-phase state machine that serialises equip and unequip. If something is in hand, the intent is stored, the unequip montage is played, and the new item enters when that one ends. The item spawns hidden and a notify halfway through the animation reveals it: there is never a frame with the item floating in the idle pose.

Por qué gana a lo obvio

Asignar la variable y lanzar un multicast se rompe de tres maneras conocidas: el objeto salta a la mano antes de que la mano se mueva, pulsar rápido desincroniza cliente y servidor, y un multicast lanzado desde un cliente no replica.

Why it beats the obvious

Assigning the variable and firing a multicast breaks in three known ways: the item jumps to the hand before the hand moves, pressing fast desyncs client and server, and a multicast fired from a client does not replicate.

Dos decisiones concretas

  • Un solo campo de intención en vez de una cola: tres giros de rueda equipan el último objeto, no los tres en fila.
  • El objeto equipado es un actor real anclado al socket, no una malla intercambiada. Cuesta más en red, pero permite que la linterna tenga su spotlight y el llavero su propio AnimBP.

Two concrete decisions

  • A single intent field instead of a queue: three wheel scrolls equip the last item, not all three in a row.
  • The equipped item is a real actor attached to the socket, not a swapped mesh. It costs more over the network, but it lets the flashlight have its own spotlight and the keyring its own AnimBP.

Ruta

RPC al servidor → mutación autoritativa → multicast de animación, con RepNotify para que los simulated proxies reconstruyan el enganche sin depender de haber recibido el RPC.

Path

RPC to the server → authoritative mutation → animation multicast, with RepNotify so simulated proxies rebuild the attachment without depending on having received the RPC.

Proyecto 02

Project 02

HillClimb3D

Recreación 3D de Hill Climb Racing.

3D recreation of Hill Climb Racing.

Unreal Engine 5.8 · física arcade sobre Chaos Vehicles

Unreal Engine 5.8 · arcade physics on top of Chaos Vehicles

Base de partida. Parte del Advanced Vehicle Template de Epic. Lo mío es el componente de asistencia arcade en C++, el GameMode y el setup completo del vehículo Chaos.
Starting point. Built on Epic's Advanced Vehicle Template. Mine is the arcade assist component in C++, the GameMode, and the full Chaos vehicle setup.

2.1Control aéreo arcade

2.1Arcade air control

C++ sobre Chaos Vehicles

C++ on top of Chaos Vehicles

Problema

El coche tiene que girar en el aire con el acelerador y aterrizar sobre las ruedas. Chaos simula un rígido con cuatro ruedas y no tiene ninguna noción de esa intención. Además, en pendientes fuertes a baja velocidad la fricción del neumático patina y el coche se queda muerto en la cuesta.

Problem

The car has to rotate in the air with the throttle and land on its wheels. Chaos simulates a rigid body with four wheels and has no notion of that intent. On top of that, on steep slopes at low speed the tyre friction slips and the car dies on the hill.

Por qué falla lo obvio

Rotar el actor directamente en el aire (SetActorRotation o interpolar el rotador) se rompe de tres formas. Mover kinemáticamente un cuerpo que el solver simula descarta su velocidad angular, así que el coche se congela al soltar y se pierde toda la inercia. En el aterrizaje el rígido llega con una rotación que el solver no predijo, las ruedas penetran el terreno y el coche salta. Y con substepping asíncrono, el hilo de juego escribe la transform mientras el de física integra desde la anterior: jitter visible.

Why the obvious fails

Rotating the actor directly in the air (SetActorRotation, or interpolating the rotator) breaks in three ways. Moving a body the solver is simulating kinematically discards its angular velocity, so the car freezes on release and all inertia is lost. On landing the rigid body arrives with a rotation the solver did not predict, the wheels penetrate the terrain and the car jumps. And with async substepping, the game thread writes the transform while the physics thread integrates from the previous one: visible jitter.

Qué decidí

El solver nunca deja de ser la autoridad. Se le habla solo con AddForce y AddTorqueInRadians, y se le añade el único estado que no expone cómodamente: si el coche toca suelo, calculado con un raycast propio a lo largo del eje local del chasis. El trazo rota con el coche, así que en una rampa a 40° sigue apuntando perpendicular a la carrocería. Ese flag reparte todo lo demás: en suelo, empuje de tracción; en aire, torque de pitch sobre el eje lateral del chasis.

What I decided

The solver never stops being the authority. It is spoken to only with AddForce and AddTorqueInRadians, and it is given the one piece of state it does not expose conveniently: whether the car is touching ground, computed with my own raycast along the chassis local axis. The trace rotates with the car, so on a 40° ramp it still points perpendicular to the body. That flag dispatches everything else: on ground, traction push; in air, pitch torque about the chassis lateral axis.

El detalle que hace que se sienta bien

Auto-nivelado con banda muerta. Al soltar el acelerador en el aire, un torque proporcional al ángulo de morro endereza el coche solo, así que el jugador nunca se queda con un aterrizaje imposible, pero mientras pulsa tiene control total. Es un asistente de aterrizaje disfrazado de física. Todo se aplica como aceleraciones y no como fuerzas, para que el comportamiento no dependa de la masa.

The detail that makes it feel good

Auto-levelling with a dead band. On releasing the throttle in the air, a torque proportional to the nose angle straightens the car by itself, so the player never ends up with an impossible landing, but while holding it they keep full control. It is a landing assist disguised as physics. Everything is applied as accelerations rather than forces, so behaviour does not depend on mass.

2.2Detección de vueltas

2.2Flip detection

C++ · integración de velocidad angular

C++ · angular velocity integration

Problema

Dar puntos por cada 360° completo en el aire, en las dos direcciones, encadenando vueltas, y solo mientras el coche no toca suelo.

Problem

Award points for every full 360° in the air, in both directions, chaining flips, and only while the car is not touching ground.

Por qué falla lo obvio

Leer el pitch del rotador y detectar el cruce de ±180 no funciona, y no por precisión. En Unreal el pitch está confinado a [-90, 90]: el valor sube a 90, rebota y baja, mientras roll y yaw pegan un salto de 180. La vuelta es literalmente invisible en el pitch. Es gimbal lock, no se arregla con épsilons. Y aunque no lo fuera, el pitch del mundo no es la vuelta del coche: una vuelta en una rampa girada 30° en yaw no es una rotación de pitch mundial.

Why the obvious fails

Reading the rotator pitch and detecting the ±180 crossing does not work, and not because of precision. In Unreal, pitch is confined to [-90, 90]: the value climbs to 90, bounces and comes back down, while roll and yaw jump by 180. The flip is literally invisible in pitch. It is gimbal lock; epsilons do not fix it. And even if it were not, world pitch is not the car's flip: a flip on a ramp rotated 30° in yaw is not a world pitch rotation.

Qué decidí

No se leen ángulos en ningún momento. Se integra la velocidad angular proyectada sobre el eje derecho del propio coche. Es continua, no tiene wrap ni singularidades, y al estar en el marco del cuerpo funciona sea cual sea la orientación de la pista. Al superar 360° se resta conservando el signo en vez de poner a cero, así las vueltas encadenadas no pierden el resto y un doble backflip cuenta dos. Como el signo se conserva, backflip y frontflip puntúan igual sin código extra.

What I decided

No angles are read at any point. The angular velocity projected onto the car's own right axis is integrated. It is continuous, has no wrap or singularities, and being in body space it works whatever the track orientation. On passing 360° it is subtracted preserving the sign instead of zeroed, so chained flips do not lose the remainder and a double backflip counts twice. Because the sign is preserved, backflip and frontflip score the same with no extra code.

HC_ArcadeAssistComponent.cpp
void UHC_ArcadeAssistComponent::DetectFlip()
{
    if (!VehiclePhysicsComponent) return;

    // Se integra velocidad angular, no angulos de Euler: el pitch de un FRotator
    // esta confinado a [-90,90] y una vuelta completa nunca llega a acumularse.
    FVector AngularVel = VehiclePhysicsComponent->GetPhysicsAngularVelocityInDegrees();
    float PitchRate = FVector::DotProduct(AngularVel, VehiclePhysicsComponent->GetRightVector());

    if (!bIsGrounded)
    {
        AccumulatedPitch += PitchRate * CachedDeltaTime;
        if (FMath::Abs(AccumulatedPitch) >= 360.f)
        {
            if (AHC_GameMode* GM = Cast<AHC_GameMode>(GetWorld()->GetAuthGameMode()))
            {
                GM->AddPoints(PointsPerFlip);
            }
            // Se resta en lugar de poner a cero: las vueltas encadenadas
            // conservan el resto y un doble backflip puntua dos veces.
            AccumulatedPitch -= FMath::Sign(AccumulatedPitch) * 360.f;
        }
    }
    else
    {
        AccumulatedPitch = 0.f;
    }
}

Publicados

Published

Otros proyectos

Other projects

Take Care (Of Me)

Unreal Engine · Steam

Juego de historia de unos 30 minutos, programado íntegramente por mí. Sistema de interacción resuelto con interfaz de Blueprint sobre una clase base de objeto interactuable.

A roughly 30-minute story game, programmed entirely by me. Interaction system solved with a Blueprint interface over an interactable object base class.

York's Pizza Express

Unreal Engine · itch.io

Arcade de reparto contrarreloj. IA simple de perros, destinos de entrega aleatorios tomados de un array de puntos del mapa, bonificación por tiempo y respawn con sistema de partículas tras ser atropellado.

Time-attack delivery arcade. Simple dog AI, random delivery destinations taken from an array of map points, time bonus, and respawn with a particle system after being run over.

MultiParty

Prototipo

Prototype

Cuatro jugadores en local, cuatro minijuegos. Proyecto temprano.

Four players local, four minigames. Early project.

The Secret of the Village

Prototipo

Prototype