Ray Runaway



What is it

RayRunaway initial gif

This is the game born during the fail-safe 2025 gamejam, since then it changed drastically and now it's a project inspired by Sword of the Sea. I mainly did programming, my work consisted in everything related to the 3Cs, all of it in C++, but I also worked on some technical art parts like the sand shader and character control rig and animations procedurally generated.

How I made it





Camera


slide 1

Camera Movement Test

slide 2

Update CamSphere Position

Our intentions were to give the possibility to the player to play the game without touching the right analog stick or the mouse. To achieve this type of behaviour I first played and studied many 3D platformers (especially Mario Galaxy). The main function of the camera actor component is the UpdateSpherePosition(...), it handles the camera position based on player pos. relative to the center of the sphere, if the player moves outside of the sphere radius, it updates the sphere pos., (the debug sphere helps visualize the function, when it turns red it starts following the player).

Follow Movement


Camera Zoom And Movement

From the sphere function the FollowRotation(...), FollowMovement(...) and ZoomOnSpeed(...) functions are called. The zoom on speed simply increase the camera boom length and fov when sprinting. The follow movement function interpolates the previous camera boom position to the newly calculated one, this mixed with the Follow rotation function helps simulate the camera boom elastic feeling.

Follow Rotation


Camera FollowRotation

The follow rotation function was the hardest to implement and involved multiples iterations. It activates only if the player is not moving the view and is not falling. It calculates the dot product between the character direction and the camera direction, then if this normalized dot prod. dir. is higher then an arbitrary value (0.7), meaning that the two directions don't differ that much but the player is not moving straight, the camera starts interpolate its rotation to follow the one of the character, it does so by calculating the cross product between the dir.s that gives the rotation orientation. From this function, because it needed the direction the player is facing, the UpdateCameraPitch(...) function is called.

Update Camera Pitch


Camera FollowRotation

The UpdateCameraPitch(...) casts a ray from the camera boom position (not from the character, for more control on camera placement), it uses the Unreal function GetSlopeDegreeAngles(...) to calculate the pitch of the surface the player is currently on, given the impact's normal. It multiplies this pitch angle with the direction from the Follow Rotation function because if the character is facing the camera and descending the slope, we want the camera to move up to better adjust to the movemnt and don't clip the ground.

Camera Parameters



// Parameters -----------

// ----------- Follow
UPROPERTY(EditAnywhere, Category = "Follow")
float FollowRotationSpeedNoInput = 1.0f;

UPROPERTY(EditAnywhere, Category = "Follow")
float TimeToResetCamera = 0.1f;

UPROPERTY(EditAnywhere, Category = "Follow")
float ResetCameraSpeed = 1.0f;

UPROPERTY(EditAnywhere, Category = "Follow - Pitch")
float CameraPitchSpeed = 0.1f;

UPROPERTY(EditAnywhere, Category = "Follow - Pitch")
float CameraBoomMinPitch = -60.0f;

UPROPERTY(EditAnywhere, Category = "Follow - Pitch")
float CameraBoomMaxPitch = 60.0f;



float CameraDownTraceValue = 400.0f;
float InitialCameraPitch = 0.0f;


// ----------- Zoom
UPROPERTY(EditAnywhere, Category = "Zoom")
float MinArmLength = 400.0f;

UPROPERTY(EditAnywhere, Category = "Zoom")
float MaxArmLength = 450.0f;

UPROPERTY(EditAnywhere, Category = "Zoom")
float ZoomInterpSpeed = 1.5f;

UPROPERTY(EditAnywhere, Category = "Zoom")
float MinSpeedFOV = 90.0f;

UPROPERTY(EditAnywhere, Category = "Zoom")
float MaxSpeedFOV = 130.0f;
  


// ----------- Sphere
UPROPERTY(EditAnywhere, Category = "Sphere")
float Sphere_MinRadius = 75.0f;

UPROPERTY(EditAnywhere, Category = "Sphere - Follow")
float CameraFollowSpeed = 1.5f;
        

In this code block you can see all the default parameters used for the camera. I provided a documentation (next gif) to the designers for every parameter, so they could better understand how the systems work and tweak the values to their likings.

Superliminal inspired level gif

Documentation given to designers




Character Controller


slide 1

Movement Example

slide 2

Curves Examples

The movement is not so complex, it has simple mechanics but it was very hard to achieve a satisfying result and even now after several weeks of iterations (at first it was physics based) it can be improved a lot. Almost every aspect of the movement is based on float curves (second slide), I chose them for two main reasons, they give smoother movement control and they are very easy to tweak from a designer point of view, of course I needed to use variables too, that I described in the documentation seen before.

Orbit Move Function


slide 1

OrbitMove Implementation

slide 2

DoMove Implementation

Orbit Move call

slide 3

Current Ray Movement

slide 4

Sword of the sea reference

One of the most important function for the ray movement is OrbitMove(...), it has this name because when looking at Sword of the sea I noticed that when pressing the lateral keys the character drew a circle with its subsequent positions and it reminded me satellites in orbit. For this same reason as can be seen in the first image, commented, there is my first implementation, that added mov. inputs in a direction tangent to a circle (it didn't feel right). After studying SoS deeper I realised that movement was connected to the camera position and direction (I might be wrong), and I implemented it in the newer version.

Jump


slide 1

Jump Example

slide 2

Prepare Jump

slide 3

Jump Release

The jump can be holded to increase the height, if you reach the maximum holding time the character performs a different animation. In PrepareJump() a ray cast is performed to avoid the possibility of spamming the dive button, you can dive only if the the ray doesn't hit the terrain meaning you are distant enough. Jump(), which starts at the release, calculates the height based on the character velocity and in order to properly jump along slopes I add the z component of the linear velocity because the standard Unreal movement component set it to zero every frame, it is also useful for the feeling beacause the higher you go the higher you jump (for this same reason is clamped).


Landing and obstcles avoidance


slide 1

On landed override

slide 2

Ray collision test

In this game some obstacles can be surpassed by going unedrground, to implement this I added a collision channel in the engine settings and with ChangeCollisionToAvoid(bool .) I set the collision response of the character capsule component to block or ignore them. So the character is always on the gorund but the illusion of immersion is given by the animation that plays which translates it and scales it down. On landing depending on if in the diving state and depending on the height of the jump start position, it plays different feedbacks reactions (the vertex ripple effect, different animations, sounds).

Animation BP


Expedition Love Letter gif

Here you can see all the states used for animating the ray. All the animations are shown on the right and I created them with the procedural control rig described in the next section.

Ray Parameters




	// Parameters -----------
	
	// Air Control

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Air")
	float AirControlMultiplier = 2.0f;


	// ----------- Jump
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump")
	UCurveFloat* WalkingJumpingCurve;
		
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump")
	UCurveFloat* SprintingJumpingCurve;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump")
	UCurveFloat* DivingGravityScale;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump")
	UCurveFloat* ImmergingRumbleScale;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump", meta = (ClampMin = "0.0", ClampMax = "4000.0", UIMin = "0.0", UIMax = "4000.0"))
	float MaxBaseJumpVelocity = 3000.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump", meta = (ClampMin = "-2000.0", ClampMax = "0.0", UIMin = "-2000.0", UIMax = "0.0"))
	float MinBaseJumpVelocity = -1000.0f;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Jump", meta = (ClampMin = "0.0", ClampMax = "10.0", UIMin = "0.0", UIMax = "10.0"))
	float MaxHoldJumpActionTime = 2.0f;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Jump", meta = (ClampMin = "0.0", ClampMax = "10.0", UIMin = "1.0", UIMax = "10.0"))
	float FallingGravityScale = 3.5f;
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Jump", meta = (ClampMin = "0.0", ClampMax = "10.0", UIMin = "1.0", UIMax = "10.0"))
	float NormalGravityScale = 2.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump")
	float StartJumpZ = 0.0;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump")
	float HigherHeightAnimation = 1700.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump")
	float LowerHeightAnimation = 200.f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump", meta = (ClampMin = "200.0", ClampMax = "2000.0", UIMin = "200.0", UIMax = "2000.0"))
	float DivingDownardCheck = 600.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump", meta = (ClampMin = "100.0", ClampMax = "500.0", UIMin = "100.0", UIMax = "500.0"))
	float DivingTailProceduralStrength = 200.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump", meta = (ClampMin = "0.0", ClampMax = "5.0", UIMin = "0.0", UIMax = "5.0"))
	float EmersionRumble = 0.25f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump", meta = (ClampMin = "0.0", ClampMax = "5.0", UIMin = "0.0", UIMax = "5.0"))
	float DivingRumble = 0.1f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump", meta = (ClampMin = "0.0", ClampMax = "5.0", UIMin = "0.0", UIMax = "5.0"))
	float EmersionRumbleDuration = 0.3f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump", meta = (ClampMin = "0.0", ClampMax = "5.0", UIMin = "0.0", UIMax = "5.0"))
	float DivingRumbleDuration = 0.2f;


	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Jump")
	bool bActivateImmergingRumble = false;


	// ----------- Orbital Movement
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Orbital Movement")
	float DefaultOrbitAngularSpeed = 65.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Orbital Movement")
	float KeyboardDiagonalInputSpeed = 25.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Orbital Movement")
	UCurveFloat* DiagonalMovementCurve;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Orbital Movement")
	UCurveFloat* TurningCurve;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Orbital Movement")
	float TurnOnSpotTolerance = 0.05f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Orbital Movement")
	float FastTurnVelocityTreshold = 500.0f;

	float OrbitAngularSpeed = 0.0f;
	float CurrentOrbitAngle = 0.0f;

	// ----------- Tilting
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tilting")
	float SmoothTiltRollSpeed = 5.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tilting")
	float SmoothTiltPitchSpeed = 5.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Tilting")
	FVector LineDownwardOffset = FVector(0.0f, 0.0f, -400.0f);

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tilting")
	FVector FrontArrowPos;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tilting")
	FVector BackArrowPos;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tilting")
	FVector RightArrowPos;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tilting")
	FVector LeftArrowPos;

	// ----------- Camera

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera", meta = (ClampMin = "1.0", ClampMax = "10.0", UIMin = "1.0", UIMax = "10.0"))
	float HorizontalSensitivity = 10.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera", meta = (ClampMin = "1.0", ClampMax = "10.0", UIMin = "1.0", UIMax = "10.0"))
	float VerticalSensitivity = 10.0f;

	UPROPERTY(EditAnywhere, Category = "Camera", meta = (ClampMin = "0.1", ClampMax = "5.0", UIMin = "0.1", UIMax = "5.0"))
	float MinSensitivityMultiplier = 0.1;

	UPROPERTY(EditAnywhere, Category = "Camera", meta = (ClampMin = "0.1", ClampMax = "5.0", UIMin = "0.1", UIMax = "5.0"))
	float MaxSensitivityMultiplier = 5.0f;

	UPROPERTY(EditAnywhere, Category = "Camera", meta = (ClampMin = "0.0", ClampMax = "10.0", UIMin = "0.0", UIMax = "10.0"))
	float TimeToResetCamera = 2.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite,  Category = "Camera", meta = (ClampMin = "0.0", ClampMax = "10.0", UIMin = "0.0", UIMax = "10.0"))
	float LookInactivityDelay = 2.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
	float MaxPitchValue = 25.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
	float MinPitchValue = -45.0f;

	// ----------- Camera Shakes

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera-Shakes")
	TSubclassOf DivingImmersionShakeClass;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera-Shakes")
	TSubclassOf EmersionShakeClass;


	// ----------- Procedural Animations

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Procedural Anim")
	UCurveFloat* BodyStrengthCurve;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Procedural Anim")
	UCurveFloat* TailStrengthCurve;

	// ---------- Animations

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Animation")
	UAnimMontage* HigherLandingAnim;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Animation")
	UAnimMontage* SoftLandingAnim;
        

In this code block you can see almost all the parameters used for the RayCharacter. Almost all those variables are part of the documentation shown at the end of the camera section.




Control Rig


Control Rig gif

Control Rig showcase

The Control Rig is divided into 3 parts, the construction event (discussed in the next paragraph), the Forward Solve and Backward Solve. The Forward Solve is used to bind controls' transforms to the skeleton, and the Backward Solve do the opposite. In this control rig we can chose between two modes, one that has all the controls for creating animations, and one used during gameplay. To be able to create this control rig I studied Unreal documentation and I also followed this incredible video.

Construction


slide 1

Construction Example

slide 2

Construction Function

The Construction event is used to generate the controls for the corresponding bones, to do so we use the function shown in the second image. The most important node is, of course, the "spawn transform control" to which we pass its parent, the shape, the bone we need to control and the scale (the if on the scale is used beacause if we pass an array of bones the controls will be scaled more or less depending on the chain scale we set). For this approach to work we need to call this construction function for each discrete part of the mesh, for example in our case we call this function seperately for the tail, the wings, the mustaches, etc.

Procedural Animations


Procedural anim logic

Procedural Animations logic

The procedural animation logic is done, as shown in the image, with the set translation and rotation nodes that receive the values from spring interpolation. The second "for each" is there to control differently the spring interpolation of the tail, because it is a slim part of the mesh, it needs an higher spring strength to avoid some artifacts. It's important to exclude from these layers of animation the bones (in the "item array") that we don't want to be affected (for example in our case the bones near the eyes or at the start of the mustaches).




Sand Shader


Superliminal inspired level gif

Sand Waves and ground ripple

The sand shader was created because we wanted to give a little bit of life to the landscape the ray runs on and add some feedbacks on high jumps (the ground ripple). It was one of the hardest task to complete because we needed to work around various limitations from both the engine version (for example unreal 5 removed the tessellation option from materials) and because my limited knowledge on shaders and landscapes.



Superliminal inspired level gif

Sand Waves

Initially our goal was to let the ray to be inlfuenced by the waves movement, unfortunately I couldn't implement it in the limited time I had because changing the landscape collisions at runtime was too difficult (now I'm studying how to achieve it). So instead I proposed a vertex shader (in the image) that could simulate the waves movement (it behaves similarly to flags) and it changes the intensity of the effect based on the camera position. This allows us to have a stable ground near the ray and waves in the distance (as shown in the previous gif).



slide 1

Ripple Vertex Shader

slide 2

Sword of the sea Ripple Shader

slide 3

Our Ripple Vertex Shader

For the landing effect we wanted something similar to what happens in sword of the sea (in the gif), it was harder than I thought because the landscape we used didn't have an high density of vertices near the ray due to the lack of the tessellation function in UE5 materials. So what I did is set the landscape to be the maximum resolution possible and increased the circle width parameter that is used in the divide before the sin so that it can affect multiple vertices at a time. The next steps I'll do to improve the effect are creating a second layer of ripple and modifying the height with a sin as it progresses.