7 People
4 WEEKS
Movement
Screen FX
Game trailer
Project Kenon
You are a stranded astronaut cut off from your ship, drifting through a field of asteroids and wreckage in a Scrap System. With gravity pulling you toward destruction, you must use precision, momentum, and sheer willpower to survive.
Kenon is a first-person space traversal game built around control, tension, and motion. Every movement matters — too much force and you spin out of control. Stay still too long, and your sanity begins to break.
Genre: Narrative-Focused Psychological Science Fiction Survival
Responsibilities: Movement, Screen FX, Generalist
Here is a more depth walk through of what I made during this project.
Overview
The movement in this project was designed as a two-part system. A space-like movement and planetoid-esque movement. I implemented a player controller that dynamically aligns the camera and character rotation based on gravity directions, ensuring smooth, predictable orientation across non-standard environments such as zero-gravity or walking on curved surfaces.
Handles walking and alignment on planetary or irregular surfaces(does struggle on surfaces not very sphere like).
Smoothly orients the player and camera to follow the gravity direction without snapping or jitter.
Uses quaternions to warp view rotation as gravity changes, keeping controls intuitive.
Ensures modularity so additional effects or mechanics can be added easily.
Gravity Controller
#include "GravityController.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
void AGravityController::UpdateRotation(float DeltaTime)
{
FVector GravityDirection = FVector::DownVector;
if (ACharacter* PlayerCharacter = Cast<ACharacter>(GetPawn()))
{
if (UCharacterMovementComponent* MoveComp = PlayerCharacter->GetCharacterMovement())
{
GravityDirection = MoveComp->GetGravityDirection();
}
}
FRotator ViewRotation = GetControlRotation();
if (!LastFrameGravity.Equals(FVector::ZeroVector))
{
const FQuat DeltaGravityRotation = FQuat::FindBetweenNormals(LastFrameGravity, GravityDirection);
const FQuat WarpedCameraRotation = DeltaGravityRotation * FQuat(ViewRotation);
ViewRotation = WarpedCameraRotation.Rotator();
}
LastFrameGravity = GravityDirection;
ViewRotation = GetGravityRelativeRotation(ViewRotation, GravityDirection);
FRotator DeltaRot(RotationInput);
if (PlayerCameraManager)
{
ACharacter* PlayerCharacter = Cast<ACharacter>(GetPawn());
PlayerCameraManager->ProcessViewRotation(DeltaTime, ViewRotation, DeltaRot);
ViewRotation.Roll = 0;
SetControlRotation(GetGravityWorldRotation(ViewRotation, GravityDirection));
}
APawn* const P = GetPawnOrSpectator();
if (P)
{
P->FaceRotation(ViewRotation, DeltaTime);
}
}
FRotator AGravityController::GetGravityRelativeRotation(FRotator Rotation, FVector GravityDirection)
{
if (!GravityDirection.Equals(FVector::DownVector))
{
FQuat GravityRotation = FQuat::FindBetweenNormals(GravityDirection, FVector::DownVector);
return (GravityRotation * Rotation.Quaternion()).Rotator();
}
return Rotation;
}
FRotator AGravityController::GetGravityWorldRotation(FRotator Rotation, FVector GravityDirection)
{
if (!GravityDirection.Equals(FVector::DownVector))
{
FQuat GravityRotation = FQuat::FindBetweenNormals(FVector::DownVector, GravityDirection);
return (GravityRotation * Rotation.Quaternion()).Rotator();
}
return Rotation;
}
Player Movement Script for
void APlayerCharacter::UpdateWalking(float DeltaTime)
{
FRotator RelativeRotation = PlayerController -> GetGravityRelativeRotation( GetControlRotation(), GravityDir);
FRotator GravityWorldRot = PlayerController->GetGravityWorldRotation(RelativeRotation, GravityDir);
FVector GravityRightVector = FRotationMatrix(GravityWorldRot).GetUnitAxis(EAxis::Y);
AddMovementInput( GravityRightVector, MoveInput.Y);
FVector GravityForwardVector = FRotationMatrix(GravityWorldRot).GetUnitAxis(EAxis::X);
AddMovementInput( GravityForwardVector, MoveInput.X);
SetGravityDirection();
UpdateCamera(DeltaTime);
}
void APlayerCharacter::UpdateTransitionToWalk(float DeltaTime)
{
FVector Forward = GetControlRotation().Vector();
FVector Up = -GravityDir;
FVector Right = FVector::CrossProduct(Up, Forward).GetSafeNormal();
Forward = FVector::CrossProduct(Right, Up).GetSafeNormal();
FMatrix RotMatrix = FRotationMatrix::MakeFromXZ(Forward, Up);
FRotator TargetRotation = RotMatrix.Rotator();
FQuat CurrentQuat = Capsule->GetComponentRotation().Quaternion();
FQuat TargetQuat = TargetRotation.Quaternion();
FQuat NewQuat = FMath::QInterpTo(CurrentQuat, TargetQuat, DeltaTime, TransitionSpeed);
Capsule->SetWorldRotation(NewQuat, false, nullptr, ETeleportType::TeleportPhysics);
SetGravityDirection();
TransitionTime += DeltaTime;
float AngleDiff = FQuat::ErrorAutoNormalize(CurrentQuat, TargetQuat);
if (TransitionTime >= TransitionDuration && AngleDiff < 0.01f)
{
ChangeMoveState(MovementMode::Walking);
TransitionTime = 0.f;
}
}
Free-floating movement in zero-gravity with full 3D directional control.
Implements forces, push-offs, and camera-controlled rotations for pitch, yaw, and roll.
Handles out-of-control physics responses (collisions, angular velocity) while maintaining player input responsiveness.
Separate update logic allows the system to remain stable and extensible.
Player Movement Script
void APlayerCharacter::UpdateWalking(float DeltaTime)
{
FRotator RelativeRotation = PlayerController -> GetGravityRelativeRotation( GetControlRotation(), GravityDir);
FRotator GravityWorldRot = PlayerController->GetGravityWorldRotation(RelativeRotation, GravityDir);
FVector GravityRightVector = FRotationMatrix(GravityWorldRot).GetUnitAxis(EAxis::Y);
AddMovementInput( GravityRightVector, MoveInput.Y);
FVector GravityForwardVector = FRotationMatrix(GravityWorldRot).GetUnitAxis(EAxis::X);
AddMovementInput( GravityForwardVector, MoveInput.X);
SetGravityDirection();
UpdateCamera(DeltaTime);
}
void APlayerCharacter::UpdateTransitionToWalk(float DeltaTime)
{
FVector Forward = GetControlRotation().Vector();
FVector Up = -GravityDir;
FVector Right = FVector::CrossProduct(Up, Forward).GetSafeNormal();
Forward = FVector::CrossProduct(Right, Up).GetSafeNormal();
FMatrix RotMatrix = FRotationMatrix::MakeFromXZ(Forward, Up);
FRotator TargetRotation = RotMatrix.Rotator();
FQuat CurrentQuat = Capsule->GetComponentRotation().Quaternion();
FQuat TargetQuat = TargetRotation.Quaternion();
FQuat NewQuat = FMath::QInterpTo(CurrentQuat, TargetQuat, DeltaTime, TransitionSpeed);
Capsule->SetWorldRotation(NewQuat, false, nullptr, ETeleportType::TeleportPhysics);
SetGravityDirection();
TransitionTime += DeltaTime;
float AngleDiff = FQuat::ErrorAutoNormalize(CurrentQuat, TargetQuat);
if (TransitionTime >= TransitionDuration && AngleDiff < 0.01f)
{
ChangeMoveState(MovementMode::Walking);
TransitionTime = 0.f;
}
}
I was also handling making post processing effects. It involved making, speed lines, damage effect and a death effect. The bigger part of this rather than just making the effects it involved making the a post process effect script making it easy to add and all the post process effects.
While creating the individual post-processing effects (speed lines, damage, and death/fade) was important, the larger focus was building a centralized post-process controller.
This script allows effects to be easily added, modified, and even combined dynamically. One function handles activation, while another manages updates over time, enabling smooth transitions and seamless integration with gameplay.
Post processing controller
#include "PostProccessController.h"
#include "PlayerCharacter.h"
UPostProccessController::UPostProccessController(): SpeedEffectMaterials(nullptr), FadeToBlackMaterial(nullptr),
DamageEffectMaterial(nullptr),
GlitchEffectMaterial(nullptr),
PlayerMove(nullptr), Camera(nullptr)
{
PrimaryComponentTick.bCanEverTick = true;
}
void UPostProccessController::BeginPlay()
{
Super::BeginPlay();
PlayerMove = Cast<UCharacterMovementComponent>(GetOwner()->GetComponentByClass(UCharacterMovementComponent::StaticClass()));
Camera = Cast<UCameraComponent>(GetOwner()->GetComponentByClass(UCameraComponent::StaticClass()));
FadeToBlackMaterial = LoadObject<UMaterialInterface>(
nullptr,
TEXT("/Game/Main/Art/Materials/M_DeathEffect_PP.M_DeathEffect_PP")
);
if (!FadeToBlackMaterial)
{
UE_LOG(LogTemp, Error, TEXT("Failed to load M_DeathEffect_PP! Check the path."));
}
SpeedEffectMaterials = LoadObject<UMaterialInterface>(
nullptr,
TEXT("/Game/Main/Art/Materials/M_SpeedLines_PP.M_SpeedLines_PP")
);
if (!SpeedEffectMaterials)
{
UE_LOG(LogTemp, Error, TEXT("Failed to load M_SpeedLines_PP! Check the path."));
}
DamageEffectMaterial = LoadObject<UMaterialInterface>(
nullptr,
TEXT("/Game/Main/Art/Materials/M_DamageEffect_PP.M_DamageEffect_PP")
);
if (!DamageEffectMaterial)
{
UE_LOG(LogTemp, Error, TEXT("Failed to load M_DamageEffect_PP! Check the path."));
}
GlitchEffectMaterial = LoadObject<UMaterialInterface>(
nullptr,
TEXT("/Game/Main/Art/Materials/M_GlitchEffect_PP.M_GlitchEffect_PP")
);
if (!GlitchEffectMaterial)
{
UE_LOG(LogTemp, Error, TEXT("Failed to load M_GlitchEffect_PP! Check the path."));
}
EffectMaterials.Add(SpeedEffectMaterials);
EffectMaterials.Add(FadeToBlackMaterial);
EffectMaterials.Add(DamageEffectMaterial);
EffectMaterials.Add(GlitchEffectMaterial);
TArray<FPPEffect> InitializedEffects;
for (auto Mat : EffectMaterials)
{
if (Mat)
{
FPPEffect NewEffect(Mat);
NewEffect.MID = UMaterialInstanceDynamic::Create(Mat, this);
NewEffect.CurrentWeight = 1.0f;
CameraPPSettings.WeightedBlendables.Array.Add(FWeightedBlendable(NewEffect.CurrentWeight, NewEffect.MID));
InitializedEffects.Add(NewEffect);
UE_LOG(LogTemp, Warning, TEXT("Material STUFF %s"), *Mat->GetName());
}
}
PPEffects = InitializedEffects;
if (Camera)
{
Camera->PostProcessSettings = CameraPPSettings;
UE_LOG(LogTemp, Warning, TEXT("UPostProccessController::BeginPlay - Camera found on %s"), *GetOwner()->GetName());
}
else
{
UE_LOG(LogTemp, Warning, TEXT("UPostProccessController::BeginPlay - Camera not found on %s"), *GetOwner()->GetName());
}
StartSpeed *= 100.0f;
MaxSpeed *= 100.0f;
//SetWeight(3 , 0.0f); // Glitch effect off by default
}
void UPostProccessController::TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
float FadeToBlackDuration = CalculateFadeDuration(SavedAlpha, 4.0f, FadeInSpeed);
float FadeBackDuration = CalculateFadeDuration(4.0f, 0.0f, FadeOutSpeed);
TotalFadeDuration = FadeToBlackDuration + FadeBackDuration;
if (bIsDeathAnim) HandleDeathAnim( DeltaTime);
if (PlayerMove && PPEffects.Num() > 0)
{
// SpeedEffect
float Velocity = PlayerMove->Velocity.Size();
float SpeedAlpha = FMath::Clamp((Velocity - StartSpeed) / (MaxSpeed - StartSpeed), 0.0f, 1.0f);
float RemappedValue = FMath::Lerp(50.0f, MaxOpacity, SpeedAlpha);
if (PPEffects[0].MID)
{
PPEffects[0].MID->SetScalarParameterValue("Opacity", RemappedValue);
}
}
}
void UPostProccessController::ApplyOxygenEffect(float MaxOxygen, float CurrenOxygen)
{
if (IsDeathAnim() || !Camera)
{
return;
}
// Fade to black for oxygen
if (PPEffects.Num() > 1 && PPEffects[1].MID)
{
// Darkening for oxygen
//if (!(CurrenOxygen <= 0.7f * MaxOxygen)) return;
float NormalizedLoss = FMath::Clamp((MaxOxygen - CurrenOxygen) / MaxOxygen, 0.0f, 1.0f);
float DeathAlpha = FMath::Pow(NormalizedLoss, Exponent);
float DeathRemapped = FMath::Lerp(0.0f, MaxFadeAlpha, DeathAlpha);
if (DeathRemapped > ThreshHoldGrayScale * MaxFadeAlpha)
{
Camera->PostProcessSettings.bOverride_ColorSaturation = true;
float SaturationPercent = FMath::Clamp(1.0f - (DeathRemapped / MaxFadeAlpha), 0.0f, 1.0f);
Camera->PostProcessSettings.ColorSaturation = FVector4(SaturationPercent, SaturationPercent, SaturationPercent, SaturationPercent);
}
else
{
Camera->PostProcessSettings.bOverride_ColorSaturation = false;
Camera->PostProcessSettings.ColorSaturation = FVector4(1, 1, 1, 1);
}
PPEffects[1].MID->SetScalarParameterValue("Fading", DeathRemapped);
SavedAlpha = DeathRemapped;
}
}
void UPostProccessController::SetWeight(int Index, float NewWeight)
{
PPEffects[Index].CurrentWeight = NewWeight;
for (FWeightedBlendable& Blendable : CameraPPSettings.WeightedBlendables.Array)
{
if (Blendable.Object == PPEffects[Index].MID)
{
Blendable.Weight = NewWeight;
break;
}
}
if (Camera)
{
Camera->PostProcessSettings = CameraPPSettings;
}
}
float UPostProccessController::GetWeight(int Index)
{
return PPEffects[Index].CurrentWeight;
}
void UPostProccessController::DamagePulse()
{
if (PPEffects.Num() > 3 && PPEffects[3].MID)
{
CurrentDmgIntensity = 0.0f;
CurrentIntensity = 0.0;
CurrentGlitchStrength = 0.0f;
bPulseFadingIn = true;
PPEffects[2].MID->SetScalarParameterValue(TEXT("DmgIntensity"), CurrentDmgIntensity);
PPEffects[2].MID->SetScalarParameterValue(TEXT("Intensity"), CurrentIntensity);
PPEffects[3].MID->SetScalarParameterValue(TEXT("GlitchStrength"), CurrentGlitchStrength);
GetWorld()->GetTimerManager().ClearTimer(PulseTimerHandle);
GetWorld()->GetTimerManager().SetTimer(PulseTimerHandle, this, &UPostProccessController::UpdateDamagePulse, 0.016f, true);
}
}
void UPostProccessController::UpdateDamagePulse()
{
// Damage Effect
if (!PPEffects.IsValidIndex(3) || !PPEffects[3].MID)
{
GetWorld()->GetTimerManager().ClearTimer(PulseTimerHandle);
return;
}
const float Delta = GetWorld()->GetDeltaSeconds();
if (bPulseFadingIn)
{
CurrentDmgIntensity = FMath::FInterpTo(CurrentDmgIntensity, TargetDmgIntensity, Delta, DamageFadeSpeed);
CurrentIntensity = FMath::FInterpTo(CurrentIntensity, TargetIntensity, Delta, DamageFadeSpeed);
CurrentGlitchStrength = FMath::FInterpTo(CurrentGlitchStrength, TargetGlitchStrength, Delta, DamageFadeSpeed);
if (FMath::IsNearlyEqual(CurrentDmgIntensity, TargetDmgIntensity, 0.01f) && FMath::IsNearlyEqual(CurrentIntensity, TargetIntensity, 0.01f))
{
bPulseFadingIn = false;
}
}
else
{
CurrentDmgIntensity = FMath::FInterpTo(CurrentDmgIntensity, 0.0f, Delta, DamageFadeSpeed);
CurrentIntensity = FMath::FInterpTo(CurrentIntensity, 0.0f, Delta, DamageFadeSpeed);
CurrentGlitchStrength = FMath::FInterpTo(CurrentGlitchStrength, 0.0f, Delta, DamageFadeSpeed);
if (FMath::IsNearlyZero(CurrentDmgIntensity, 0.001f) && FMath::IsNearlyZero(CurrentIntensity, 0.001f))
{
GetWorld()->GetTimerManager().ClearTimer(PulseTimerHandle);
bPulseFadingIn = true;
CurrentDmgIntensity = 0.0f;
CurrentIntensity = 0.0f;
CurrentGlitchStrength = 0.0f;
}
}
UE_LOG(LogTemp, Warning, TEXT("CurrentDmgIntensity: %f, CurrentIntensity: %f, CurrentGlitchStrength: %f"), CurrentDmgIntensity, CurrentIntensity, CurrentGlitchStrength);
PPEffects[2].MID->SetScalarParameterValue(TEXT("DmgIntensity"), CurrentDmgIntensity);
PPEffects[2].MID->SetScalarParameterValue(TEXT("Intensity"), CurrentIntensity);
PPEffects[3].MID->SetScalarParameterValue(TEXT("GlitchStrength"), CurrentGlitchStrength);
}
void UPostProccessController::StartFadeToBlackAnim(EFadeMode NewFadeMode)
{
DeathFadeMode = NewFadeMode;
if (DeathFadeMode == EFadeMode::FadeToBlackOnly)
{
PPEffects[1].MID->SetScalarParameterValue("Fading", 0.0f);
SavedAlpha = 0.0f;
}
if (DeathFadeMode == EFadeMode::FadeToWhiteOnly)
{
PPEffects[1].MID->SetScalarParameterValue("Fading", 4.0f);
bFadeToBlackPhase = false;
SavedAlpha = 4.0f;
}
SetDeathAnim(true);
GetWorld()->GetTimerManager().SetTimer(
FadeToBlackTimerHandle,
this,
&UPostProccessController::ResetValues,
GetTotalFadeDuration(),
false
);
UE_LOG(LogTemp, Warning, TEXT("StartFadeToBlackAnim - TotalFadeDuration: %f"), GetTotalFadeDuration());
}
void UPostProccessController::HandleDeathAnim(float DeltaTime)
{
// Fade to black for death
if (PPEffects.Num() > 1 && PPEffects[1].MID)
{
if ((bFadeToBlackPhase && DeathFadeMode == EFadeMode::FadeToWhiteOnly) ||
(!bFadeToBlackPhase && DeathFadeMode == EFadeMode::FadeToBlackOnly))
{
return;
}
UE_LOG (LogTemp, Warning, TEXT("HandleDeathAnim - SavedAlpha: %f"), SavedAlpha);
float TargetAlpha = bFadeToBlackPhase ? 4.0f : 0.0f;
float CurrentAlpha = SavedAlpha;
float CurrentFadeSpeed = bFadeToBlackPhase ? FadeInSpeed : FadeOutSpeed;
float NewAlpha = FMath::FInterpTo(CurrentAlpha, TargetAlpha, DeltaTime, CurrentFadeSpeed);
PPEffects[1].MID->SetScalarParameterValue("Fading", NewAlpha);
SavedAlpha = NewAlpha;
UE_LOG(LogTemp, Warning, TEXT("NewAlpha: %f, TargetAlpha: %f"), NewAlpha, TargetAlpha);
if (bFadeToBlackPhase && FMath::IsNearlyEqual(NewAlpha, 4.0f, 0.01f))
{
bFadeToBlackPhase = false;
}
}
}
void UPostProccessController::ResetValues()
{
SetDeathAnim(false);
bFadeToBlackPhase = true;
APlayerCharacter* PlayerCharacter = Cast<APlayerCharacter>(GetOwner());
if (PlayerCharacter)
{
PlayerCharacter->playerMove->Velocity = FVector::ZeroVector;
PlayerCharacter->ChangeMoveState(MovementMode::Space);
}
}
The damage animation uses the Unreal Engine’s built-in material editor, combined with a script for interpolation for smooth, responsive transitions. By blending a bleed effect with a glitch effect, the system creates a dynamic and impactful visual response. This approach allows the post-process effects to be easily controlled via code, enabling seamless integration with gameplay events such as damage, oxygen loss, and death.
The death effect is a standalone post-processing effect triggered when the player dies. It uses a material-driven fade (to black or white) that is smoothly interpolated over time using the post-process controller, creating a cinematic and responsive visual transition.
It was a great learning experience in unreal testing what works and what doesn't, teaching me thing's I haven't done before. I also got to expierence how much more one can achieve by being organized in a project working together far more tightly as a team instead of multiple people working together but more individually.
Game Design / Product Owner: Daniel Karlberg
System Design: Duarte Susano
Level Design: Cecilia Wretemark-Hauck
Sound Design: David Johansson
Gameplay Programming: Isak Sigvardsson
Visionary Programming / Scrum Master: Max Deurell Kent