top of page

role Gameplay programmer
software Unreal Engine
framework Blueprint, C++, GAS
duration 9 weeks (+ 6)

date Fall 2024, Spring 2026
team size 9
type PC multiplayer video game
---
steam
Here
github 
Here
full playthrough Here

tailwind tropics
 

 

 

 

overview.

Tailwind Tropics is a multiplayer round-based team party game where two teams of three compete across a three-phase loop of gathering, crafting and naval combat. A full match is decided over a best of 3 rounds, with a typical session running 12–18 minutes. The game is designed to be easy to pick up, short enough to always fit a session in, and competitive enough to keep players coming back. Each round consists of three phases that flow directly into one another. What a team achieves in one phase shapes their conditions in the next.

​

I originally built this together with fellow students during my final year at Stockholm University. Afterwards, while working full time, we took the project on as a side effort, restructuring the codebase from the ground up and converting the majority of the logic from Blueprint to C++ with GAS implemented.

​

​

​

boat character

​​​​development​​.

For this project I was working on the boat character system, which is the core gameplay object of the naval combat phase. Rather than building a monolithic character class, the system is split across several focused classes that each own a distinct responsibility and communicate through Unreal's Gameplay Ability System (GAS) and delegates.


At the top sits ABoatCharacter, which acts as the owner and coordinator of the entire system. It holds a custom movement component, a Gameplay Ability System Component, and a data asset reference that drives the boat's stats and visuals.


Why GAS?

RPCs are a perfectly valid approach for multiplayer gameplay, and for many projects they are the right tool, offering straightforward control over exactly what gets sent and when. An earlier iteration of this system used that pattern: each ability called a server RPC, the server updated a replicated variable, and clients reacted in OnRep functions etc.


That said, one of the goals for this project was to gain hands-on experience with GAS, since it's widely used in larger Unreal productions and handles a lot of multiplayer complexity that would otherwise need to be built manually. Abilities as self-contained classes, built-in prediction, automatic replication through the ability system component, and gameplay effects managing attribute changes. These are all things GAS provides out of the box that would require significant custom work with raw RPCs at scale.


So while the RPC approach was functional, migrating to GAS was an intentional learning decision as much as an architectural one. The result is that ABoatCharacter itself is now very lean, and adding new abilities or gameplay states is very simple and straight forward.


State as tags
The key design principle throughout is that state lives in GAS tags. Instead of boolean flags scattered across classes, gameplay states like "is scurrying" or "is charging a shot" are represented as FGameplayTags on the Ability System Component. Any class that needs to react to those states simply subscribes to tag change events, keeping the classes loosely coupled from each other.

​

Classes

  • ABoatCharacter

    • UBoatMovementComponent - handles all movement feel and rotation

    • URacketeersAbilitySystemComponent - central GAS 

      • URacketeersAttributeSet - health, speed, damage values

      • UGAbility_BoatShoot - cannon firing ability

      • UGAbility_BoatStartScurry - speed dash ability

    • UBoatConfigDataAsset - data-driven stats, visuals, cannon layout

    • AProjectile - spawned at runtime by the shoot ability

    • BP_BoatSpawner - spawns different type of boats

       

In the following sections I go through each class, its functionality, and the reasoning behind it.

​

​

​​​​​​​

 

ABoatCharacter is the glue that holds the system together. I designed it to own the components, apply configuration at spawn time, and route player input. The goal was to keep it as thin as possible and push all actual logic into GAS and the components.


One thing worth noting is that the ASC is initialised early in PostInitializeComponents rather than BeginPlay, so it's ready to receive replicated abilities before the normal flow starts. This avoids a class of subtle bugs where the client receives a replicated ability grant before the ASC has called InitAbilityActorInfo. I also reinitialise in OnRep_Controller and OnRep_PlayerState as a safety net, since possession order isn't guaranteed across client and server.


When a boat spawns, I call ApplyBoatConfiguration, which reads from a UBoatConfigDataAsset and a team assignment. This single function handles everything: it writes the starting health and MaxHealth directly into the URacketeersAttributeSet via the ASC, sets MaxWalkSpeed on UBoatMovementComponent, applies the correct team-specific hull and sail materials by looking up named material slots (Hull, Sail) on the skeletal mesh, and populates CannonLeftComponents and CannonRightComponents by iterating all USceneComponents and matching against the tag lists defined in the config asset. From that point on, ABoatCharacter has no knowledge of which boat variant it is because all differences live in the data.


Player input is split into two paths. Movement input goes directly to UBoatMovementComponent::ProcessMoveInput. Ability input (shooting and scurrying) goes through AbilityInputPressed and AbilityInputReleased, which call into my custom AbilityLocalInputPressed on the ASC, forwarding an EAbilitySlot cast to int32 as the InputID. The binding itself is data-driven: I iterate AbilityInputBindings.Bindings at setup time and bind each UInputAction to the corresponding slot with Enhanced Input's ETriggerEvent::Started and ETriggerEvent::Completed. This means the character class has zero knowledge of what any individual ability does. Adding a new ability only requires registering it in the binding table.


The one area where ABoatCharacter acts as a bridge is damage. Rather than handling damage events directly, I bind to delegates on the URacketeersAttributeSet. OnHealthChanged and OnDeath, which fire Blueprint-exposed events for VFX, audio, and game flow. All damage, whether from projectiles or ramming, flows through ApplyDamage, which constructs a FGameplayEffectSpecHandle from UGE_Damage, sets the damage magnitude via SetSetByCallerMagnitude using the Data.Damage tag, and applies it to the ASC. This means everything goes through one path regardless of source, so future modifiers like damage reduction automatically apply everywhere.


Damage-state visuals are driven by two Gameplay Cues, a light damage cue at 60% health and a heavy damage cue at 30%. I add and remove these from the server in UpdateDamageStateCues, which is called from HandleHealthChanged. Because AddGameplayCue and RemoveGameplayCue replicate through the ASC automatically, no manual multicast is needed.


Ramming uses a dedicated UBoxComponent (RammingCollisionBox) with OverlapAllDynamic collision. On overlap, I first check IsScurrying() on the movement component, which queries the ASC for Boat.State.Scurrying and bail out immediately if the tag isn't present. If it is, I check a RecentRamTargets map to enforce a per-target cooldown, then either call ApplyRammingDamage directly on the server or route through ServerApplyRammingDamage from the client, which re-validates the cooldown server-side before applying. The ram impact itself fires a GameplayCue on the victim's ASC, which handles the Niagara VFX, sound, and a camera shake that I apply only to the victim's local controller by checking IsLocalController() inside GCue_BoatRam.

​

​configuration data asset
The UBoatConfigDataAsset is what makes it practical to support multiple boat variants without any code changes. It holds a FBoatConfiguration struct that defines everything that differs between boats: max health, base movement speed, ram damage, projectile damage, per-team hull and sail materials, and the component tag lists that identify the left and right cannons.


Designers can create a new boat variant entirely in the editor by duplicating a data asset and adjusting values. The cannon layout is particularly flexible. Rather than hardcoding how many cannons a boat has, the config asset holds a list of component tags for each side. InitializeCannons iterates all USceneComponents on the mesh and matches them against those tag lists to populate CannonLeftComponents and CannonRightComponents. This means a boat can have one, two, or three cannons per side just by tagging the right mesh components and updating the data asset, with no code changes required.

​​

​

​

boat movement

 

The movement component translates raw stick input into something that feels like a boat while keeping that snappy arcade feel. The core of this is ProcessMoveInput, which runs on every input event and does three things: calculates a desired world-space direction from camera orientation using the player camera manager's yaw, blends that direction with the boat's current forward vector weighted by CalculateSpeedFactor (current speed divided by MaxWalkSpeed, clamped 0–1), and then calculates a new rotation via FMath::RInterpConstantTo at a speed that depends on current state.


The speed–direction blend solves a specific problem I ran into: at high speed, if the boat responds freely to input, the nose lags behind the actual movement direction and the boat visually points one way while moving another. By lerping between the desired direction and the current forward vector based on speed, the boat at low speed responds freely to input since it hasn't built up momentum yet so the nose and movement stay aligned. At high speed it strongly favours its current heading.


Rotation speed is handled differently depending on state. When charging, I lerp between MaxRotationSpeedShooting and MinRotationSpeedShooting based on speed factor, scaled by input magnitude, so aiming at speed feels controlled. Outside of charging, I use a flat MaxRotationSpeed, with a SharpTurnMultiplier applied when the angle between current heading and desired heading exceeds SharpTurnThreshold. This makes large course corrections feel deliberate rather than sluggish.


I deliberately avoided polling GAS tags every tick. Instead, in BeginPlay I register tag change callbacks for ScurryStateTag and ChargingStateTag using RegisterGameplayTagEvent, and broadcast OnScurryStateChanged and OnChargingStateChanged delegates from those callbacks. The movement component itself only reads state when it needs to. GetMaxSpeed overrides the base class and applies a 2.5× multiplier for scurry or a 0.75× multiplier for charging, checking IsScurrying() and IsChargingShoot() at that point. This keeps the tick clean and avoids redundant tag queries every frame.


The component also tracks moving state via UpdateMovingState, comparing velocity against zero each tick and broadcasting OnMovingStateChanged when that flips, which is used to play a wake particle effect when the boat is moving.


Rotation is applied locally and sent to the server via ServerSetRotation RPC when not authoritative. This is one of the few places I kept a direct RPC rather than routing through GAS, since rotation is continuous input data rather than an ability event. It would be unnecessary overhead to model it as a gameplay effect.

​

Code | Main functionality for boat rotation

 

Code | Helper functions for handling boat rotation

 

 

cannon firing

 

The shoot ability (UGAbility_BoatShoot) implements a hold-to-charge, release-to-fire mechanic using LocalPredicted execution so charging feels instant on the client while the server stays authoritative over what actually fires.


On activation I split the work by authority. On the server I initialise BoatCharacter->ShootRange to InitialShootRange, set bIsShootingLeft, and add the Character.State.Charging.Shoot tag to the ASC. On the locally controlled client I call StartCharging_Local, which adds the same tag immediately via AddLooseGameplayTag, guarded by a HasMatchingGameplayTag check to prevent double-activation. I then spin up a UAbilityTask_WaitInputRelease task that calls OnInputReleased when the player lets go.


ShootRange itself is incremented each tick in ABoatCharacter::UpdateShootRange while the charging tag is active, clamped to MaxShootRange. It's replicated via DOREPLIFETIME so the server always has the current charge value when firing.


On release I apply cooldown and cost via ApplyCooldown and ApplyCost only at this point and not on activation because I only want the cooldown to start when the player actually fires, not when they start charging. I then call ExecuteShooting, which calls FireCannons and ReloadCannons.


FireCannons iterates the appropriate cannon array (CannonLeftComponents or CannonRightComponents) and schedules each shot on a timer with FireDelay * i offset to produce a sequential volley. Inside each timer, I go through the cannon's child components looking for ProjectilePos and ParticlePos tags to find the spawn point and the Niagara component. I then spawn an AProjectile at that location, pass it the charged range, and execute the GameplayCue.Boat.Shoot cue via ExecuteGameplayCue, passing the cannon's USkeletalMeshComponent as TargetAttachComponent inside FGameplayCueParameters. The cue (AGCue_CannonShoot) uses this to play the fire animation directly on the cannon mesh and attach the muzzle flash Niagara effect to the correct socket.


The reload sound is handled by ReloadCannons, which queries the cooldown duration for the Ability.Cooldown.Shoot tag via my custom GetCooldownRemainingForTag on the ASC, then sets a timer for exactly that duration. When it fires, it executes the GameplayCue.Boat.Reload cue, so the reload audio plays precisely when the ability becomes available again, with no additional state tracking needed.

 

Code | Main functionality for shooting cannons




cannon ball/projectile

The projectile (AProjectile) is intentionally minimal in C++. Its C++ side handles the physics, collision detection, and triggering a Gameplay Cue on impact. When it hits a boat, it fires the impact cue via the boat's ASC so the visual and audio response is driven through GAS like everything else. When it hits terrain or other objects, it fires a simpler impact event.


Actual damage application and the arc/range behaviour are implemented in Blueprint via exposed events (OnHitBoat, SetShootRange). This split keeps the C++ class reusable and testable, while allowing designers to tune the cannonball's flight feel and damage values without recompiling.

​

​

​

scurry & ram

 

The scurry ability (UGAbility_BoatStartScurry) is configured as LocalPredicted and InstancedPerActor. Its entire effect on gameplay comes down to a single tag: Boat.State.Scurrying.


On activation, the client adds the tag immediately via AddLooseGameplayTag in StartScurry_Local, guarded by bIsActiveLocally to prevent double-application. The server adds it independently in its own authority branch. This split means the speed boost and ramming capability are active on the client without waiting for a server round-trip. I then schedule OnDurationElapsed via UAbilityTask_WaitDelay, which calls EndAbility after the fixed duration.


On end, the client removes its local copy of the tag in StopScurry_Local and the server removes the authoritative copy. The tag being present on both client and server is intentional. The movement component reads it locally for visual speed, while the ramming collision check happens server-side.


Visual and audio feedback is handled entirely through the GameplayCue.Boat.Scurry cue (AGCue_BoatScurry). I fire it locally from the client for instant feedback and from the server for all other clients. OnActive attaches a set of Niagara effects to the skeletal mesh using sockets defined in a TMap, and plays the start sound, but only for the locally controlled player, checked via IsLocalController(), to avoid double-playing on the owning client.

OnRemove deactivates and destroys each UNiagaraComponent individually, and the cue itself has bAutoDestroyOnRemove set so it cleans itself up after a short delay.

​​

​

​

boat setup & spawning

The URacketeersAttributeSet owns the boat's numerical state: current health, max health, and movement speed. All damage, whether from projectiles or ramming, flows through GAS as a Gameplay Effect targeting the Damage meta-attribute. The attribute set processes each effect, subtracts from health, and broadcasts an OnHealthChanged delegate with the new value, the damage amount, and the instigator.


Routing all damage through one place means the character doesn't need separate handling for different damage sources. It also ensures that any future modifier like a damage reduction effect or a shield automatically applies to all damage types without changes to the sources. When health reaches zero the attribute set fires OnDeath, which the character picks up to trigger the destruction sequence.


Spawning boats with correct values from phase 2
When the naval combat phase begins, each boat needs to spawn with the correct stats based on what that team upgraded during phase 2. I handle this in Blueprint by looping through each player and reading their ShipParts struct, which holds three enums: ShipParts, ShipSails, and ShipHulls. I compare those values against a data table that defines all possible boat configurations, and from that lookup I determine which attribute set to apply to the boat.


I also check each player's team assignment and use that to determine the correct spawn position and hull color, which is defined by actor components I've placed in the world. Each iteration spawns the boat with the correct configuration, and the loop exits once all players have been processed.


Like the interaction system, this is currently implemented in Blueprint and I plan to convert it to C++ in the future.

​

​

​

interaction

I implemented the interaction system in Blueprint using an interaction interface. The interface defines a function that any interactable object can implement, meaning the player character doesn't need to know what it's interacting with. Instead it just calls the interface method on whatever is in range and the object handles the rest. This keeps the player code clean and makes adding new interactable objects straightforward.


When a player interacts with a boat, the boat gets possessed by the interacting player and the camera lerps smoothly to the boat's camera position. A VFX and sound plays on the player at the moment of interaction. While a player is inside the interaction zone, the boat displays an outline to signal that interaction is available. This is driven by OnComponentBeginOverlap and OnComponentEndOverlap events on the boat's trigger volume, which adds and removes the outline accordingly.


I plan to convert this to C++ in the future, in line with the rest of the codebase. The interface structure translates directly to a UInterface in C++, so the conversion is mostly a matter of moving the logic rather than redesigning it.

​​

​

​

gameplay cues & URacketeersAbilitySystemComponent

Most of the feedback in the system like VFX, sound and camera shake is routed through Gameplay Cues rather than direct multicast RPCs. This is one of the bigger practical benefits of GAS I found during development: cues replicate automatically through the ASC and handle their own lifecycle, which removed a whole category of manual networking code I would have otherwise needed.


I extended the base ASC with URacketeersAbilitySystemComponent to add a few things the base class doesn't support cleanly. First, local cue execution methods: ExecuteGameplayCueLocal, AddGameplayCueLocal, and RemoveGameplayCueLocal bypass replication entirely and fire directly through the GameplayCueManager. I use these for the owning client's immediate feedback (scurry start, charge start) so there's no perceived latency on the player's own machine. Second, MakeEffectContextWithHitResult is a convenience wrapper that creates an effect context and embeds a FHitResult in one call, used when applying damage to pass impact data through to the attribute set. Finally, GetCooldownRemainingForTag queries active gameplay effects by tag and returns both time remaining and total duration, which the shoot ability uses to schedule the reload sound timer.

​

​

​

takeaways.

The most valuable thing I took away from this project was genuine comfort with GAS in a multiplayer context. Before this, I had read the documentation, followed tutorials and worked with singleplayer GAS projects, but there is a significant gap between understanding GAS conceptually and actually debugging why an ability activates on the client but not the server, or why a Gameplay Cue fires twice, or why predicted tags desync under certain conditions. Working through those problems hands-on gave me an understanding of how GAS actually behaves at runtime that I couldn't have gotten any other way.


Local prediction worked really well in practice. Once the authority boundaries were set up correctly, the abilities felt instant on the owning client while the server stayed in control of what actually mattered.


Working on this while employed full time also taught me a lot about scoping and sustainable pacing. I got much better at identifying the smallest change that moves something forward meaningfully, rather than trying to tackle large refactors in limited evening hours. Restructuring the codebase from Blueprint to C++ in that context forced me to be disciplined about what I touched and why, which I think made the final architecture cleaner than it would have been if I had done it all at once.


More broadly, this project made me a lot more confident reading and working within Unreal Engine systems. GAS in particular has a deep and sometimes dense codebase, and getting comfortable navigating it and reading engine source to understand why something behaves a certain way, is a skill that carries over to any large codebase.

bottom of page