
What I built
my contribution — nothing here is third-party work
- Ability framework: an abstract ScriptableObject `Ability` with Projectile, Aura, Whip and Instant variants, instantiated per run so levels and cooldowns are per-player; damage rolls with crit chance from a cloned `UnitStatistics` sheet.
- Projectile behaviour (`MoveTo`): homing, piercing, bouncing with smart re-targeting to the nearest living enemy, and explosive variants with radial knockback.
- Passive items that additively modify the runtime stat sheet (damage, cooldown, range, speed, knockback, magnet range, extra bounces) and level up like weapons.
- Object pooling on top of `UnityEngine.Pool.ObjectPool`, keyed by prefab name, shared by enemies, projectiles, VFX, damage popups and XP gems; every pooled object resets itself in `OnEnable`.
- Wave spawner that places enemies on the NavMesh around the player on a timeline of waves.
- Enemy behaviour: NavMeshAgent chase, contact damage with interval, physics knockback that hands control back to the agent, hit-flash and dissolve-death via a Shader Graph material.
- Level-up flow: XP gems with a recoil-then-fly pickup animation, XP curve, a three-card upgrade screen that only offers valid options (free slots, level caps), controller/keyboard selection.
- Save system: `ISaveSystemElement` discovery, JSON save and load of level, XP, kills, position, abilities and passives, optional XOR obfuscation.
- HUD (XP bar, timer, kill counter, inventory slots, world-space health bars), pause menu with animated panel, game-over screen, Cinemachine helpers (dynamic FOV when moving, angle-based zoom).
The game
Horde Hours is a top-down 3D survivors-like. You move; your weapons fire on their own at the nearest enemies. Enemies arrive in timed waves and chase you across a NavMesh arena, drop XP gems when they die, and every level-up pauses the game to offer three upgrade cards: a new weapon, a new passive, or a level for one you already have. The run ends when your health hits zero. There is no win state — the score is how long you lasted and how many you took down.
My goal for this 3D survivors-like was to balance player power progression with time-based difficulty scaling by architecting a modular, data-driven ability system using Unity’s ScriptableObjects. This project taught me how to tie independent mechanics into a cohesive core loop while scoping features to prioritize scalable functionality.
Technical breakdown
1. Abilities as data
Every weapon is a ScriptableObject asset derived from Ability: name, icon, VFX prefab, cooldown, range, damage range, damage type, targeting mode and level cap. Concrete subclasses override Execute — a projectile spawns a pooled MoveTo object and aims it, an aura attaches a pulsing DamagingAura to the player, a whip spawns an AreaDamage volume in front of the player. The controller clones the assets at runtime so each run has its own levels and cooldown timers, and passives mutate a cloned UnitStatistics sheet that every ability reads through. Adding a weapon means adding an asset, not touching the controller.
The whole loop is one method on the player. Every frame, each ability ticks its own cooldown; when it is ready, it asks for a target and executes:
private void HandleAbility(Ability ability) {
ability.cooldownTimer -= Time.deltaTime;
if (ability.cooldownTimer > 0) return;
GameObject target = GetTargetForAbility(ability);
bool targetFound = target != null;
bool isNonTargetedAbility = ability.targetingMode == TargetingMode.Self || ability.targetingMode == TargetingMode.Directional;
if (targetFound || isNonTargetedAbility) {
ability.Execute(runtimeStatistics, gameObject, target);
ability.cooldownTimer = ability.cooldown * runtimeStatistics.cooldownMultiplier;
}
}
AbilitiesController.cs — HandleAbility, unedited.
Damage is rolled in one place that every weapon shares, so a passive that raises the damage multiplier or crit chance improves every weapon at once:
public int GetDamage(UnitStatistics stats, out bool isCritical) {
float baseDamage = Random.Range(minDamage, maxDamage);
float multiplier = stats.damageMultiplier;
isCritical = Random.value <= stats.critChance;
if (isCritical) {
multiplier *= stats.critMultiplier;
}
return Mathf.RoundToInt(baseDamage * multiplier);
}
Ability.cs — GetDamage, unedited.
2. One pool for everything
Enemies, projectiles, hit VFX, damage popups and gems all go through one ObjectPoolManager that builds a UnityEngine.Pool.ObjectPool per prefab and keys it by prefab name. Spawning returns an inactive instance and activates it; despawning deactivates and releases. Each pooled script resets its own state in OnEnable (timers, collected flags, agent warp) so an instance can be reused without a special reset call. Death effects run a flash-and-fade before the enemy is returned to the pool.
3. Level-up offers and saves
LevelUpManager turns every enemy kill into XP, drives the XP curve, and builds the three-card offer from the ability and passive databases: owned items appear as “level N+1” only below their cap, unowned ones only while a slot is free. The screen pauses time, unlocks the cursor and preselects the first card for controller play. The save system finds every ISaveSystemElement in the scene, lets each write into one SaveData object, serialises it to JSON in the persistent data path and restores abilities by name from the database on load.
What changed for the web build (October 2026)
- A real main menu (Play, Credits, Quit) replaced the empty first scene.
- The Static Field effect used VFX Graph, which browsers don’t support; it is now a particle-system ring driven by the same
DamagingAuralogic. - The XP-magnet range check was inverted, so the magnet passive did nothing. Fixed, along with a frame-rate-dependent wave timer, a save field that was written backwards, and a pointer lock the game never needed.
- Forward+ rendering switched to Forward, audio moved to Vorbis, textures capped at 1024 px for the web.
Gallery


Credits
third-party assets used — with thanks
| Asset | Author | Licence |
|---|---|---|
| Character Pack: Free Sample | Supercyan | Asset Store EULA |
| Character Pack: Zombie Sample | Supercyan | Asset Store EULA |
| Level 1 Monster Pack | PI Entertainment Limited | Asset Store EULA |
| Stylized Free Skeleton | ThunderForgeStudio | Asset Store EULA |
| FREE – RPG Weapons | Blink | Asset Store EULA |
| Simple Gems and Items Ultimate Animated Customizable Pack | BenjaTheMaker | Asset Store EULA |
| Comic Explosion Effect | CartoonVFX9x | Asset Store EULA |
| (Free) StylizedVFX Fire Pack | Hun0FX | Asset Store EULA |
| Slash Effects FREE | Matthew Guz | Asset Store EULA |
| Hit Impact Effects FREE | Travis Game Assets | Asset Store EULA |
| VFX – Impact and Hit – Light Version | Cartoon VFX by Wallcoeur | Asset Store EULA |
| Zap VFX – URP | Vefects | Asset Store EULA |
| Stylized Grass Texture | LowlyPoly | Asset Store EULA |
| Free – Casual & Relaxing Game Music Pack (track "Happy Harmony") | SLD Audio | Asset Store EULA |
| Hints Stars Points & Rewards Sound Effects – Lite Pack | Cyberwave Orchestra | Asset Store EULA |
| Rat model (mesh and texture) | Original modeller unknown — the file came rigged through Mixamo. If this is your model, please contact me for credit or removal. | Unknown |
| Flair dance animation (retargeted) | Mixamo (Adobe) | Mixamo terms, royalty-free |
| League Gothic font | The League of Moveable Type | SIL OFL |
| Caslon Antique font | Typographer Mediengestaltung | 1001Fonts Free For Commercial Use |
| Ability icons | AI-generated | n/a (generated) |