Orbital Defense — Tower Placement Driven by Per-Orbit Energy Budgets
Not 'place as many towers as you can afford' — each orbit has its own budget. Using constraint-based design to create interesting decisions, plus the SfxEvent queue pattern and dual-tick system.
Opening — The "Just Spam the Strongest Tower" Problem
When I first prototyped Orbital Defense, the placement limit was simple — "if you have enough energy, place anywhere". After a few playthroughs I saw the issue.
- Once resources pile up, spamming the strongest tower became the optimal single strategy
- No reason to leverage each orbit's unique character
- Weak motivation to retry
The old saying "constraints are gameplay" came back to me. This post documents the per-orbit energy budget system I introduced, plus the technical puzzles I hit along the way.
Design — Budget per Orbit
Each of the 5 orbits has a cap on placement cost.
const ORBIT_ENERGY_BUDGET = [60, 80, 100, 120, 140];
// inner (low orbit) has smaller budgets; outer has larger
- Outer orbits have bigger budgets but enemies take longer to reach — forward-base role
- Inner orbits have smaller budgets — last line of defense, only high-efficiency towers allowed
- The core of strategy becomes "what combination should each orbit have?"
Only placement cost counts toward the cap (upgrades use a separate pool). This single rule forced a different composition every run.
Placement Rejection — Four Reasons
I classified "can't place" into four cases to make UX feedback clear:
| Reason ID | Meaning |
|---|---|
noCost | Not enough total energy |
maxTotal | 15-tower cap exceeded |
orbitBudget | Exceeds this orbit's budget |
tooClose | Less than 40px from an existing tower |
Each rejection surfaces an on-canvas speech bubble explaining why. Eliminates the "I have enough money, why can't I place?" frustration.
Three Places to Keep in Sync
Placement logic fires in three places:
placeTower()— the actual placement attemptgetPlaceReject()— reason shown in UIhandleCanvasMouseMove— real-time feasibility on hover
All three must follow identical rules for consistent UX. Any rule change requires updating all three — keep a checklist.
Speech Bubble UX
Simply reddening the cursor on "can't place" is less informative than telling the user what to fix.
const REJECT_LABELS: Record<PlaceRejectReason, string> = {
noCost: 'Not enough energy',
maxTotal: 'Max 15 towers',
orbitBudget: 'Orbit budget exceeded',
tooClose: 'Too close to another tower',
};
On hover, compute the reject reason at that position and show the bubble. The user instantly gets "oh, I need the next orbit out".
Tech Puzzle 1 — The SfxEvent Queue Pattern
Early on, SFX trigger logic looked like this:
// ❌ Inefficient — O(n²) every frame
const newProjectiles = next.projectiles.filter(p => !prev.projectiles.some(pp => pp.id === p.id));
newProjectiles.forEach(p => playTowerFireSFX());
At 60fps with 30 towers firing, that's per-frame n² comparison + array creation + GC spikes. Frame drops ensued.
Fix: push into a queue the moment the logic fires the event; consume O(n) in the render/sound loop.
type SfxEvent =
| { type: 'towerFire'; towerIdx: number }
| { type: 'enemyAttack'; enemyId: string }
| { type: 'stealthReveal' };
// inside logic
function updateGameState(state) {
// ... tower fire decision
state.sfxQueue.push({ type: 'towerFire', towerIdx });
}
// container
useEffect(() => {
for (const ev of state.sfxQueue) {
switch (ev.type) {
case 'towerFire':
playTowerFireSFX();
break;
// ...
}
}
state.sfxQueue = [];
});
O(n) consumption per frame, then clear. Frame rates stabilized afterward.
Tech Puzzle 2 — Dual-Tick System
I started with a single state.tick, but it was designed to increment only during wave phase.
Problem: UI fades and animations looked frozen during non-wave phases (prep / waveEnd / menu). Because state.tick wasn't moving.
Fix: separate two ticks.
// for logic — increments only within wave phase (spawn/AI timing)
state.tick;
// for rendering — increments every RAF frame, in all phases
tickRef.current;
state.tick: game logic (enemy spawn, tower firing, cooldown)tickRef.current: visual effects (fades, animations)
With this split, menu animations still flow during prep, while game logic advances only in wave phase — exactly as intended.
⚠️ Confusing moment: if you accidentally base prep/waveEnd timing on
state.tick, it's "always 0" and the timer never runs. Comment thatstate.tickis wave-only in the type definition.
UI Safe Area
The full-screen canvas has HUD pinned top and bottom. To keep orbit rendering from hiding behind HUD, compute a safe area:
const UI_PAD_TOP = 100;
const UI_PAD_BOTTOM = 60;
const availH = ch - UI_PAD_TOP - UI_PAD_BOTTOM;
const sc = Math.min(cw / REF_W, availH / REF_H);
const cy = UI_PAD_TOP + availH / 2; // vertical center
This calculation is referenced in 4 places identically:
drawOrbitalDefense(render)- RAF loop (motion calc)
- click handler (placement)
- mousemove (hover)
If any one diverges, you get a subtle "clicked here but placed over there" bug. Extract as a shared constant with a single source of truth.
Retrospective
A reaffirmation of "constraints are gameplay". One simple budget cap produced the entire diversity of tower compositions.
Technically, the dual-tick separation and SfxEvent queue stuck with me. The former abstracts "time flowing at different speeds across phases", the latter shifts from "detect diffs between frames" to "emit events". Both are reusable in other tower-defense / wave-based games.
In strategy game design, "many options" is less interesting than "a handful of meaningful options" — a lesson I relearned through this project.
Guestbook
Leave a short note about this post
Loading...