My first game jam participation

Ahmed DjebnouneAhmed Djebnoune
5 min read

🕹️ Game Jam #3 – “Everything is a Resource”

🔧 Engine: Unreal Engine 5.6
🎮 Game: Echo Run
🎯 Theme Interpretation: Every movement you make is recorded and becomes a resource.


What's a Game Jam

A game jam is an event where game developers, designers, artists -- and any gamedev enthusiasts -- can participate in an event to create a video game from scratch within a short, fixed time frame set by the organizer. Participants can work solo or in teams, and are given a theme or specific constraints to guide their game development process. The primary focus of a game jam is creativity, experimentation, and collaboration, rather than producing a polished final product.

💡 Concept

I had the idea of duplicating yourself and making your copies a resource, so the idea of ghosts of your past runs was born. The idea is your movements and paths from the previous runs are recorded and would play out as ghosts in the next run to solve puzzles.


⚙️ Implementation:

I started off in blueprint to save time, but later on, I hit a roadblock where I needed a multidimensional array, so it had to be C++.

Movement Recorder System

  • I used a struct to store transform data per frame, since that’s all the info I needed.

  •   USTRUCT(BlueprintType)
      struct FGhostFrame
      {
          GENERATED_BODY()
    
          public:
    
          UPROPERTY(BlueprintReadWrite)
          float TimeStamp;
    
          UPROPERTY(BlueprintReadWrite)
          FVector Location;
    
          UPROPERTY(BlueprintReadWrite)
          FRotator Rotation;
    
          FGhostFrame()
             : TimeStamp(0.f), Location(FVector::ZeroVector), Rotation(FRotator::ZeroRotator)
          {}
      };
    
  • The recording function was fairly simple, but it had to be called every frame. Fortunetely, this isn’t a heavy game, so this solution will work for now.

  •   void ASPlayerCharacter::RecordGhostFrame(float TimeStamp)
      {
          FGhostFrame NewGhostFrame;
          NewGhostFrame.Location = GetActorLocation();
          NewGhostFrame.Rotation = GetActorRotation();
          NewGhostFrame.TimeStamp = TimeStamp;
          GhostMemory.Add(NewGhostFrame);
      }
    
  • Next up, we spawn the ghost in the BeginPlay of our game mode with the relevant recorded path…

    •   void AAGameModeBase::SpawnGhostWithPath()
        {
            ASPlayerCharacter* PC = Cast<ASPlayerCharacter>(GetWorld()->GetFirstPlayerController()->GetPawn());
            if(!PC) return;
      
            USGhostGameInstance* GI = Cast<USGhostGameInstance>(GetGameInstance());
            if (!GI) return;
      
            // Loop through recorded runs (likely 5) and spawn a ghost at the first frame of every run
            for (const auto& GhostRun : GI->AllGhostRuns)
            {
                // Offset the spawn location by a random value to prevent collision with the original
                float OffsetByIndex = (FMath::RandRange(-1.5f, 1.5f ) + 0.5f )* -100.f;
      
                FVector SpawnLoc = GhostRun[0].Location + FVector(0, OffsetByIndex, 0);;
                FRotator SpawnRotator = GhostRun[0].Rotation;
      
                FActorSpawnParameters Params;
                Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
      
                ASGhostCharacter* Ghost = GetWorld()->SpawnActor<ASGhostCharacter>(GhostClass, SpawnLoc, SpawnRotator, Params);
                if (Ghost)
                {
                    // Set the spawned ghost on the corresponding path
                    Ghost->SetGhostPath(GhostRun);
      
                    // Add the spawned ghost to a saved array of ghosts            
                    Ghost->SetGhostPath(GhostRun);
      
                }
            }
      
            // After the loop that spawns ghosts, remove the earliest ghost if the number is more than 5
            while (SpawnedGhosts.Num() > 5)
            {
                if (SpawnedGhosts[0])
                {
                    SpawnedGhosts[0]->Destroy();
                }
                SpawnedGhosts.RemoveAt(0);
            }
        }
      
    void ASGhostCharacter::SetGhostPath(const TArray<FGhostFrame>& InPath)
    {
        GhostPath = InPath;
        GhostPlaybackTime = 0.0f;
        CurrentFrameIndex = 0;

        if (!GhostPath.IsEmpty())
        {
           SetActorLocationAndRotation(GhostPath[0].Location, GhostPath[0].Rotation);
        }
    }
  • …and we update the character’s location on every tick by calling the required function:

  •   void ASGhostCharacter::UpdateGhostPosition(float DeltaTime)
      {
          // Don't update/Stop updating positing if there aren't enough frames, or it's an early frame, then pause animation
          if (GhostPath.Num() < 2 || (CurrentFrameIndex >= GhostPath.Num()-1))
          {
              if (GetMesh())
              {
                  GetMesh()->bPauseAnims = true;
              }
              return;
          }
    
          GhostPlaybackTime += DeltaTime;
          const FGhostFrame CurrentFrame = GhostPath[CurrentFrameIndex];
          const FGhostFrame NextFrame = GhostPath[CurrentFrameIndex + 1];
    
          float FrameDelta = NextFrame.TimeStamp - CurrentFrame.TimeStamp;
          if (FrameDelta <= 0.0f) FrameDelta = 0.01f;
    
          float Alpha = (GhostPlaybackTime - CurrentFrame.TimeStamp) / FrameDelta;
    
          FVector NewLocation = FMath::Lerp(CurrentFrame.Location, NextFrame.Location, Alpha);
          FRotator NewRotation = FMath::Lerp(CurrentFrame.Rotation, NextFrame.Rotation, Alpha);
    
          FVector DeltaMove = NewLocation - GetActorLocation();
          CalculatedVelocity = DeltaMove / DeltaTime;
    
          // Set the character's to use for animation
          GetCharacterMovement()->Velocity = CalculatedVelocity;
    
          SetActorLocation(NewLocation);
          SetActorRotation(NewRotation);
    
          if (GhostPlaybackTime >= NextFrame.TimeStamp)
          {
              CurrentFrameIndex++;
          }
    
          // We exposed these values to BP because we need them in the AnimBP to freeze the movement
          GhostSpeed = (GetActorLocation() - LastLocation).Size() / DeltaTime;
          LastLocation = GetActorLocation();
      }
    

    Note: I created a multidimensional array in the game instance to store an array of runs.

The Animation

  • The rest is pretty straightforward. I used the animation blueprint that came with the character, which worked perfectly, minus some issues. More on that later.

The Puzzles

  • I used the classic buttons, switches, retractable bridges, and elevators since time is short and you can’t really think of the best puzzles when you really need them.

Assets Used

  • Character Robot from Fab was pretty much what I needed for this.

  • The rest was default stuff from the editor, until I find better things to make it more unique.


🧠 The Problems

Buttons and Doors Repeatedly Trigger

  • When using a Timeline node to open change the location or rotation of an actor, the Interface functions that open move said actors trigger multiple times, resulting in sounds playing repeatedly like a machine gun, or interfering with any logic you use for counting.

The solution was simply to leave the visual and auditory aspects last.

The Ghost Characters Don't Animate

  • I did get stuck with the ghost character not using the movement animation at all and being always in the idle animation. After some time, I realized that the conditions for the Walk/Run animation to run were GroundSpeed > 0 and Acceleraction ≠ 0. The speed was easy to fix. I disabled the acceleration condition until another solution is found.

Ghost Times Mess

  • Ghosts were out of sync due to delta timing, so they were adjusted for fixed update loop.

Ongoing Problems

  • For some reason, I can't seem to get the ghosts to spawn where I want them to. They always spawn at PlayerStart. It worked perfectly then just stopped, no matter how much I change their spawn location. I know it's something silly and I'll berate myself for it.

--

Conclusion and What I Learned

  • Always scope tighter than you think. You always think it’s small enough, but time always seems to disagree.

  • Animation and gameplay need sync logic. It's easy for your characters to go haywire and movement is done in a primitive way.

  • I need better art workflow (or partners). Hopefully, in the future.


🗂️ Play the Game

Please try out my game. I'll hopefully update it with more fixes, before I move on to the next project. 👉 Download Echo Run on itch.io

0
Subscribe to my newsletter

Read articles from Ahmed Djebnoune directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ahmed Djebnoune
Ahmed Djebnoune