ELVERA
All Projects

ELVERA

A third-person PvP action game I built on my own in Unreal Engine and released on Steam. Two factions, a roster of heroes with their own abilities, and round-based events that change how each match plays out.

Role Sole Developer
Platform PC (Windows)
Released January 15, 2024
Development 2 years

Overview

ELVERA is a third-person PvP action game. Two factions fight for control of the map, each player picks a hero with their own abilities, and round-based events change the match as it goes. The aim was for no two matches to play out the same way.

I made it on my own, from the first prototype to the Steam release on January 15, 2024. Every system, every line of code, and every design decision is mine.


How the Systems Work

These are the main systems in the game, how they’re built, and why I built them that way.

Ability System

Every ability inherits from one base class, Ability_Base, which holds the data and functions they all share. Below it are three ability types, Primary, Secondary, and Ultimate, and each hero gets one of each. Shared behavior lives in the base class. Anything specific to one ability goes in an overridden function in its own subclass.

Each ability’s data sits in an Ability_Info struct: name, icon, category, cooldown, cast conditions, weapon requirements, animations, and flags for when and where it can be used. The base character class has a slot for each ability type, and once an ability is assigned, the rest of the game reads from that struct. Tooltips, cooldown indicators, sound cues, and gameplay logic all pull from the same place.

Activation is checked twice. The client first checks locally whether the ability can be used, then a server RPC checks again, so a modified client can’t skip the rules. Once the server agrees, it spawns an actor at the player’s location. The actor isn’t a visible object; it just carries the ability’s logic. The JumpPad, for example, spawns its mesh, sets up the overlap, launches whoever touches it (allies or enemies, depending on its settings), and disappears when its duration runs out.

Every ability also has a ForceEverythingAbilityRelatedDestruction() function that removes anything it left in the world. It runs between rounds, so each round starts clean.

The three abilities have their own input actions, mapped through a data-asset input mapping context that the player controller sets up when the player connects. Everything is replicated, so all clients see the same ability state.

Attack System

Basic attacks live in a Basic Attack Component attached to the base character class. Every hero gets melee and ranged attacks without duplicated code, and there’s one place to debug or extend them.

Attacks are combo-based. The component tracks where the player is in the combo and resets it when they stop attacking. An enum tracks the equipped weapon type (one-handed, two-handed, bow, dual blades, or unarmed), and together with the combo step it picks the right animation. The animations play in slots tied to part of the skeleton, so players can attack while running or jumping.

For melee, each character has a collision box that normally sits at their position. When an attack starts, the system reads the character’s attack speed, range, and area of effect, then moves, resizes, and rotates the box based on the player’s input and position. Ranged weapons use a line trace: take the direction from the camera to the target, normalize it, scale it by the weapon’s range, and trace to that point.

Like abilities, attacks are validated locally first and then again by the server. After a hit, the damage and its type go out to the systems that need them, like the kill tracker, health bars, and damage numbers. Character stats, items, weapons, and enchants all feed into each attack, and the whole thing is replicated.

Interaction System

I wanted one way to handle everything a player can interact with, instead of separate logic for each object. Anything interactable implements the Interactable Actor interface and inherits from InteractableActor_Base. The interface covers the interaction type, range checks, starting, cancelling, and finishing. The base class holds an InteractableActor_Info struct with the object’s name, interaction range, the distance at which its overhead widget appears, its interaction type, base duration, tick frequency, and a few other settings.

There are two types of interaction. Instant ones finish on the key press. Progressive ones need the key held for a set time, and they break if the player gets crowd-controlled, dies, moves out of range, or lets go early.

When the player presses the key, a server RPC checks that the object they’re overlapping implements the interface, then calls OnInteract(). If the checks pass (allowed teams, the player’s current state, the object’s current state), HandleStartInteraction() records who is interacting, updates the state, and shows the interaction widget. On success, HandleSuccessfulInteraction() clears the timers, resets values, and removes the UI. Each step has shared logic in the base class that a child class can override. In the game, this covers sabotaging objects, repairs, and the other interactable things on the map.

Buffs & CC System

This one is a bit unusual. Instead of separate systems for buffs and debuffs, there’s only one: a buff with a negative value is a debuff. That removed a lot of duplicate logic. Crowd control is handled in almost the same way.

The system is a component on the base character class and works with the character’s stats, which are split into base stats (set by weapons) and bonus stats (changed by buffs, debuffs, and anything else during play). ApplyBuff() takes a type, value, and source and adds an entry to the Buffs array. Recalculation functions read that array and update the character’s stats. Each entry has a GUID, so one specific buff can be removed without touching the others.

Crowd control uses ApplyCC() and RemoveCC(), with an enum for the CC type. It works through the character’s state flags: IsMoving, IsRotating, IsInAir, IsCasting, IsAnchored, IsInteracting, IsInvisible, and IsDead. Stuns, roots, and silences can stack on one character without overwriting each other, and a player can carry dozens of buffs at once. Abilities aren’t the only source, either. Weapons, enchants, and other items apply buffs, debuffs, and CC through the same functions.

Character Selection

I based this on League of Legends’ champion select. I wanted players to have enough time and information to make a real choice, to think about team composition, and to get from the lobby into the match without friction.

Character selection is its own level, with its own game state, game mode, player controller, and player state. When players travel there from the lobby, a timer first confirms that everyone has connected. Then each player gets a team and a pick order, and they pick in turns. A picked character is locked for everyone else, so no hero appears twice in a match.

Players can preview characters and their abilities before and after picking, which mostly helps new players. Each turn has a countdown shown as text and a progress bar. If someone doesn’t pick in time, the session is cancelled so neither team starts a player short, and the player who ran out the clock gets a rating penalty. If that player was the listen-server host, the penalty is doubled. There’s also team chat and all chat.

Nothing is hardcoded to a team size. The system reads how many players arrived from the lobby and works for anything from 1v1 to 3v3. Each player’s pick index, team, chosen character, and other details are stored in a PlayerInfo_CharacterSelection struct on the player state, which is used to set up the match once everyone travels to the gameplay level.


Technical Details

Design Approach

I worked gameplay-first. I’d prototype a mechanic, get it playable as fast as possible, then iterate based on how it actually felt in-game. Community playtesting was a big part of that. I ran regular sessions with players, collected feedback, and used it to shape everything from hero balance to round pacing.

The factions and round events came from wanting each match to feel unpredictable. I didn’t want players to solve the game after a few rounds, and mixing hero variety with events that change the match was my answer to that.

Balancing a PvP game alone is tough. I relied heavily on playtest data, tracked win rates across heroes and factions, and made small adjustments instead of big sweeping changes. It’s not perfect, but it taught me a lot about live balancing and reading player behavior.

Back to all projects