Building TimeBound: The Unity Architecture Behind a Mobile Survival Roguelite

ScriptableObjects, weighted upgrade drafts, signature abilities, object pooling, and recoverable local progression.
A player moves through an arena. Weapons fire automatically. Enemies close in, experience fragments drop, and a level-up pauses the action for another upgrade decision.
That is the visible loop in TimeBound, the mobile survival roguelite I'm building in Unity. Underneath it is a set of systems that need to agree about ownership, timing, targeting, progression, and what happens when an object gets reused.
The project uses Unity 6000.3.16f1 and C#. Its settings include Primordial Valley and Sands of Eternity; the hero examples in this article are Kael, with the Pulse Pistol, and Amunet, with the Sun Disc.
This is a walkthrough of the current implementation, including the tradeoffs. It is not a benchmark report: pooling and reusable buffers are implementation choices here, not evidence of a particular frame rate.
1. Separate hero definitions from a run's changing state
A hero needs an identity, starting stats, a visual prefab, a weapon, and a passive. Putting all of those decisions directly into a character controller would make that controller responsible for too many unrelated things.
TimeBound stores those definitions in a HeroDefinition ScriptableObject. Here is a shortened excerpt showing the relevant fields:
[CreateAssetMenu(fileName = "Hero", menuName = "TIMEBOUND/Hero")]
public class HeroDefinition : ScriptableObject
{
public string heroId;
public string displayName = "Kael";
public string homeScene;
public GameObject visualPrefab;
public float maxHealth = 100f;
public float moveSpeed = 8f;
public float armor;
public float pickupRadius = 3f;
public WeaponId startingWeapon = WeaponId.PulsePistol;
public HeroPassive passive = HeroPassive.None;
}
This follows Unity's asset-based use of ScriptableObjects: the definition holds configuration that scene components can reference.
HeroSetup connects that configuration to the live player. It resolves the selected or default hero, checks gameplay access, applies base stats, sets the starting weapon, prepares the visual, and adds the passive. For the Pulse Pistol and Sun Disc, it also attaches the signature ability component.
The distinction matters: a definition describes a hero; runtime components own the changing state of the current run. Persistent hero ownership and tiers live in a separate progression store.
There is also an initialization dependency. HeroSetup uses [DefaultExecutionOrder(-100)], placing it after PlayerStats initialization and before combat feedback caches the visual colors. The order matters because hit flashes must restore the hero's configured appearance.
2. Share weapon scheduling without forcing identical attacks
The Pulse Pistol and Sun Disc have different behavior, but they share questions such as:
- When can the weapon attack again?
- How do weapon ranks and player stats affect damage?
- How many projectiles should be produced?
- How does a temporary signature effect change firing?
WeaponBase owns that shared work. Its update loop is small:
protected virtual void Update()
{
if (Time.deltaTime <= 0f) return;
if (Time.time < nextFireTime) return;
Attack();
nextFireTime = Time.time + Cooldown / cooldownDivisor;
}
Subclasses implement Attack(), while the base class calculates effective values from weapon configuration, rank bonuses, and PlayerStats.
For example, the normal cooldown calculation is:
public static float Cooldown(float weaponCooldown, float attackSpeed) =>
Mathf.Max(MinCooldown,
weaponCooldown / Mathf.Max(0.01f, attackSpeed));
The minimum cooldown is 0.1f. A temporary signature boost uses a separate cooldown divisor rather than rewriting the saved or base attack-speed stat.
That separation makes temporary effects easier to end cleanly: the ordinary weapon configuration remains available throughout the ability.
3. Make targeting allocation-conscious, with an explicit limit
Auto-attacking weapons need targets. Repeatedly creating arrays of nearby colliders would add avoidable work to a frequently executed path.
The shared weapon code keeps a reusable buffer of 256 colliders and uses:
int count = Physics.OverlapSphereNonAlloc(
origin, range, TargetBuffer, EnemyMask);
It then examines only the returned entries, ignores invalid or dead enemies and explicitly excluded targets, and compares squared distances to select the nearest candidate.
Unity's OverlapSphereNonAlloc documentation describes the supplied-buffer behavior. This avoids allocating a new results array for each query; it does not make the entire attack path allocation-free.
There is a correctness tradeoff too. When more colliders overlap than fit in the buffer, the result is limited to the returned set. The chosen enemy is the nearest among those candidates, not necessarily the nearest across every collider in the area.
Dense encounters are therefore a useful profiling and validation case. Buffer saturation is something to measure, rather than assume away.
4. Build upgrade drafts from eligibility rules, then apply weights
An upgrade screen needs randomness, but it also needs rules that keep the choices valid.
TimeBound separates those concerns into:
UpgradeDefinition: the card's content and configuration.DraftRules: eligibility, selection, weighting, and reroll rules.DraftService: resolving player state, building offers, and applying selections.
A weapon card can unlock an unowned weapon or rank up an owned one. Eligibility depends on available loadout space, minimum player level, and whether that weapon can still rank up.
Stat cards track pick limits. Cards that consume slots must respect the six-card stat limit, while minor cards that do not consume slots can fill otherwise empty offer positions.
The normal offer starts with a weapon card when one is eligible, fills the remaining positions from the eligible weapon and stat pool, then uses minor cards if necessary. Candidates are removed as they are selected. Legendary cards are handled through a separate offer path.
Weights add another layer. For Kael and Amunet, the other hero's signature weapon remains available at a weight of 0.05f, compared with a normal weight of 1f.
That is a relative selection weight, not a fixed 5% probability. The actual probability depends on the other eligible candidates and their weights.
The service also doubles the weight of defensive stat cards when the run-pressure value falls below the configured threshold. This biases an offer without guaranteeing a particular defensive card.
Rerolls prefer cards absent from the current offer. If the resulting selection is the same set, the rules return an empty result so the caller can recognize that there is no changed offer to sell.
5. Treat a signature ability as a state machine
Kael's Overclock and Amunet's Eclipse need more than an activation timer. They need a precise answer to whether charge can be earned, whether activation is allowed, and what happens after the effect ends.
The shared meter has three states:
public enum SignatureState { Charging, Ready, Active }
At the meter level:
- A normal enemy kill contributes
1base charge. - An elite kill contributes
5. - Boss HP milestones contribute charge separately.
- Reaching capacity changes the state to
Ready. - Activation spends the charge and enters
Active. - Charge earned during
Activeis neither accumulated nor deferred.
The charge rule is explicit:
public float Add(float baseGain)
{
if (State != SignatureState.Charging || baseGain <= 0f)
return 0f;
float before = Charge;
Charge = Mathf.Min(Capacity,
Charge + baseGain * GainMultiplier);
if (Charge >= Capacity)
State = SignatureState.Ready;
return Charge - before;
}
The surrounding component adds event handling and gameplay restrictions. It can suppress charge for marked damage, refuses activation during blocked gameplay states, and finds the hero's own starting weapon rather than activating a drafted copy of another weapon.
Tier rules are separate from the meter. Current tiers change duration, damage, and charge multipliers. A home-era bonus and a tier-four charge bonus are added to the multiplier; they are not compounded as two successive multiplications.
Keeping the meter independent of Unity's clock makes the state transitions easier to exercise in editor checks.
6. Use different clocks for gameplay and presentation
Signature activation includes slow motion and a camera push. That introduces a timing problem: some effects should follow gameplay speed, while others should progress independently of it.
The active ability duration decreases using Time.deltaTime. Pausing gameplay or opening a draft therefore freezes its remaining duration.
The camera emphasis uses Time.unscaledTime, so its presentation timing is independent of the slow-motion scale. Unity documents this distinction for unscaled time measurements.
The code also gates gameplay activity around overlays and transitions. Choosing a clock is only part of the solution; deciding whether a system should run at all is a separate responsibility.
The mobile HUD reflects the same separation. Its signature button calls the ability's activation method, but it does not own the meter rules. It sits opposite the fixed joystick, and the ready-state edge flash does not block touches.
7. Pool objects, and make reuse a lifecycle
TimeBound's custom ObjectPooler stores queues by tag, prewarms configured instances, and tracks active counts. Spawning dequeues an available object, positions it, marks it active, enables it, and calls IPoolObject.OnObjectSpawn() when that interface is present.
Returning an object disables it and puts it back into its queue. The small guard in PoolObject is important:
internal bool TryMarkReturned()
{
if (isInPool) return false;
CancelInvoke();
isInPool = true;
return true;
}
Without that guard, two return paths could enqueue the same instance twice. A later pair of spawns could then receive the same object.
Cancelling pending invokes also matters. A delayed return scheduled during a previous use must not fire after that instance has already been reused.
The Pulse Pistol projectile shows what resetting actually involves: its spawn callback clears echo and pierce flags, resets its signature tier and movement direction, clears previously hit targets, restores scale and visuals, and schedules its return. Reusing the GameObject is only safe when the previous shot's state is removed too.
Pooling still has limits. This implementation creates another instance when an available queue is empty, so it can grow and can still instantiate during play. Reset callbacks also need to restore each object's transient state correctly.
The benefit is a reusable lifecycle that reduces repeated creation and destruction when instances are available. Its actual effect on frame time and memory needs device profiling.
8. Recover progression purchases with a local journal
Persistent progression has a different failure mode: an interrupted write could leave the wallet and hero tier disagreeing.
The progression ledger writes a pending purchase containing the intended final coin balance and hero state before applying those values. Its sequence is:
- Serialize the intended result into
PendingPurchaseand flush it. - Write the resulting coin balance and hero state, then flush again.
- Clear the journal and flush once more.
Readers and writers recover a pending journal before proceeding. Recovery writes the intended final values instead of subtracting the purchase price again, making replay idempotent for that recorded result.
The ledger also validates ownership, the expected current tier, the configured next-tier price, and the available balance. The expected-tier check helps reject a repeated purchase request based on stale UI state.
This is application-level recovery around local storage, not a database transaction or a claim of power-loss-proof storage. The backing adapter uses PlayerPrefs, which Unity documents as local, unencrypted preference storage. The journal addresses interrupted application writes; it does not make the wallet authoritative or tamper-proof.
9. Make important rules runnable outside a full encounter
The project includes editor checks for meter transitions, boss milestones, progression purchases, reload behavior, and simulated interruptions between save stages.
Examples include checking that a full meter cannot keep banking charge, an active meter rejects gain, a repeated purchase does not spend twice, and recovery restores the intended wallet and tier after an interrupted save sequence.
These checks exist in the repository; describing them here is not a claim that I reran every check for this article. They also complement rather than replace playtesting on a device.
The useful design pattern is the separation itself: DraftRules, SignatureMeter, and the storage-backed progression ledger can be exercised with controlled inputs without first reproducing an entire combat encounter.
What I want to measure next
The current architecture gives me specific questions to investigate: how often target buffers saturate in dense encounters, which pools grow during a run, and how signature feedback reads on a small screen.
Those measurements can guide the next changes more reliably than treating a design pattern as proof of performance.
TimeBound is still in development. Building it has made the connections between systems especially interesting: a temporary attack boost needs reliable cleanup, a pool needs reset rules, and a progression purchase needs both validation and recovery.
Which part would you like a deeper walkthrough of next: weighted drafts, signature abilities, or recoverable progression saves?
About the author
I am a Game Developer.
Comments
Join the discussionNo comments yet. Be the first to comment.





