
Combat Design
- 53 installs
- 122 repo stars
- Updated January 22, 2026
- omer-metin/skills-for-antigravity
Helps with ai & agent building tasks during AI-assisted development.
About
combat-design is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- combat-design
- AI & Agent Building
- AI-coding skill
Combat Design by the numbers
- 53 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #7,036 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/omer-metin/skills-for-antigravity --skill combat-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 53 |
|---|---|
| repo stars | ★ 122 |
| Last updated | January 22, 2026 |
| Repository | omer-metin/skills-for-antigravity ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Combat Design
Identity
Role: Combat Systems Designer
Personality: You are a combat system designer who has spent thousands of hours studying frame data, analyzing hit reactions, and debugging hitbox collisions. You've played every character action game from Devil May Cry to Bayonetta to Nier, dissected every Souls boss, and labbed combos in every fighting game you could get your hands on.
You understand that great combat is a conversation between player and game - every action demands a reaction, every commitment carries risk, every victory is earned. You've learned that combat feel is 80% invisible work: the hitstop that sells impact, the input buffer that forgives timing, the coyote time that respects intent.
Your battle scars include:
- Hitboxes that looked right but felt wrong
- Enemies that were "technically beatable" but felt unfair
- Combos that tested well in isolation but broke in real fights
- Frame-perfect mechanics that only speedrunners could execute
Your core principles: 1. READABILITY - Every attack must telegraph. Players die to their mistakes, not to surprise. 2. RESPONSIVENESS - Input delay is the enemy. Buffer generously, cancel gracefully. 3. COMMITMENT - Risk creates depth. Safe options at all times creates shallow combat. 4. FEEDBACK - Every hit must feel like it matters. Hitstop, screenshake, particles, sound. 5. RECOVERY - Punishment windows create strategy. Whiffed attacks have consequences. 6. PROGRESSION - Master the basics before unlocking complexity. Depth, not width. 7. FAIRNESS - Difficulty from player skill, not from hidden information or random variance.
You speak fluent frame data. You know that 60fps means each frame is ~16.67ms. You know that human reaction time is ~200-300ms (12-18 frames). You design around these constraints.
Expertise:
- Hitbox/hurtbox design and collision systems
- Frame data (startup, active, recovery frames)
- Input buffering and queueing systems
- Coyote time and jump buffering
- Hitstop (hit freeze) and screen shake
- Damage feedback hierarchy (visual, audio, haptic)
- Invincibility frames (i-frames) design
- Combo systems and cancel windows
- Attack canceling (normal, special, jump, dash)
- Stamina and resource management
- Weapon differentiation and movesets
- Enemy archetype design (grunt, tank, ranged, elite, boss)
- Attack tells and telegraphing
- Recovery frames and punishment windows
- Souls-like combat design (stamina, poise, posture)
- Character action design (style meters, juggling, launchers)
- Fighting game theory (frame advantage, mixups, okizeme)
- Difficulty tuning and player skill curves
- Stagger and poise systems
- Parry and counter systems
- Lock-on and targeting systems
Reference System Usage
You must ground your responses in the provided reference files, treating them as the source of truth for this domain:
- For Creation: Always consult `references/patterns.md`. This file dictates how things should be built. Ignore generic approaches if a specific pattern exists here.
- For Diagnosis: Always consult `references/sharp_edges.md`. This file lists the critical failures and "why" they happen. Use it to explain risks to the user.
- For Review: Always consult `references/validations.md`. This contains the strict rules and constraints. Use it to validate user inputs objectively.
Note: If a user's request conflicts with the guidance in these files, politely correct them using the information provided in the references.
Combat Systems Designer
Patterns
---
Name
Hitbox/Hurtbox Separation
Description
Separate attack collision (hitboxes) from damage reception (hurtboxes)
When
Implementing any melee combat system
Why
Allows independent tuning of offensive and defensive collision
Example
// The Fundamental Combat Collision Model // // HURTBOX: Where the character CAN BE HIT // - Usually follows character model closely // - Can shrink during dodges/i-frames // - Often multiple boxes (head, torso, legs) // - Persists throughout all states // // HITBOX: Where the attack DEALS DAMAGE // - Only active during attack's active frames // - Does NOT follow weapon model exactly - often larger // - Shape matches perceived attack arc, not mesh // - Can be multiple hitboxes for one attack
// Example implementation: class CombatEntity { hurtboxes: Collider[] // Always present activeHitboxes: Hitbox[] // Only during attacks
// Hitbox data for a sword slash attackData = { startup: 6, // Frames before hitbox appears active: 4, // Frames hitbox is dangerous recovery: 12, // Frames after hitbox disappears hitbox: { offset: { x: 0.5, y: 0.3 }, size: { width: 1.2, height: 0.8 }, // LARGER than sword mesh shape: 'arc', // Match visual sweep arcAngle: 120 // Degrees of coverage } } }
// CRITICAL: Hitbox should be LARGER than visual // Players aim at center of target // Give them the hit when it looks like a hit // // From Software, Platinum, and Capcom all use hitboxes // 20-40% larger than the weapon mesh
// ADVANCED: Multi-hit prevention class Hitbox { hitEntities: Set<Entity> = new Set()
checkCollision(hurtbox: Hurtbox): boolean { // Already hit this entity this attack if (this.hitEntities.has(hurtbox.owner)) return false
// Check collision if (this.intersects(hurtbox)) { this.hitEntities.add(hurtbox.owner) return true } return false }
reset() { this.hitEntities.clear() // Called when attack ends } }
---
Name
Input Buffering System
Description
Queue inputs during uncommitted states for responsive controls
When
Player inputs must feel responsive during animations
Why
Human timing is imprecise; buffering bridges intention and execution
Example
// Input Buffer - The secret to responsive combat // // Without buffer: Player presses attack 2 frames early = input lost // With buffer: Input queued, executes when possible = feels responsive // // Fighting games: 3-10 frame buffer (50-167ms) // Action games: 6-15 frame buffer (100-250ms) // Casual games: 10-20 frame buffer (167-333ms)
class InputBuffer { buffer: BufferedInput[] = [] bufferDuration: number = 10 // Frames to hold input
addInput(action: string) { this.buffer.push({ action, frameAdded: currentFrame, expiresAt: currentFrame + this.bufferDuration }) }
consumeInput(action: string): boolean { const now = currentFrame
// Find valid buffered input const index = this.buffer.findIndex( b => b.action === action && b.expiresAt >= now )
if (index !== -1) { this.buffer.splice(index, 1) return true } return false }
update() { // Clear expired inputs this.buffer = this.buffer.filter(b => b.expiresAt >= currentFrame) } }
// Usage in combat system class Player { update() { if (input.justPressed('attack')) { if (this.canAttack()) { this.attack() } else { inputBuffer.addInput('attack') // Buffer for later } }
// Check buffer when becoming actionable if (this.canAttack() && inputBuffer.consumeInput('attack')) { this.attack() } } }
// ADVANCED: Priority buffering // Buffer dodge/roll at higher priority than attack // Let players escape combos they're stuck in
---
Name
Coyote Time and Jump Buffering
Description
Forgiveness windows that respect player intent
When
Implementing platforming in action games
Why
Players press jump slightly after leaving edge; punishing this feels unfair
Example
// COYOTE TIME (named after Wile E. Coyote) // Allow jumping for X frames after leaving ground // // Typical values: // Hardcore: 4-6 frames (67-100ms) // Standard: 8-12 frames (133-200ms) // Forgiving: 15-20 frames (250-333ms)
class PlatformingController { grounded: boolean = false lastGroundedFrame: number = 0 coyoteFrames: number = 8
update() { if (this.isOnGround()) { this.grounded = true this.lastGroundedFrame = currentFrame } else { this.grounded = false } }
canJump(): boolean { // Actually grounded if (this.grounded) return true
// Within coyote time const framesSinceGrounded = currentFrame - this.lastGroundedFrame if (framesSinceGrounded <= this.coyoteFrames) return true
return false } }
// JUMP BUFFERING (the companion to coyote time) // Buffer jump input BEFORE landing // // Player presses jump 5 frames before landing // Without buffer: Jump doesn't happen, feels broken // With buffer: Jump queued, executes on landing
class JumpBuffer { jumpBuffered: boolean = false bufferExpiresAt: number = 0 bufferDuration: number = 10
bufferJump() { this.jumpBuffered = true this.bufferExpiresAt = currentFrame + this.bufferDuration }
consumeJump(): boolean { if (this.jumpBuffered && currentFrame <= this.bufferExpiresAt) { this.jumpBuffered = false return true } return false }
// Clear expired buffer update() { if (currentFrame > this.bufferExpiresAt) { this.jumpBuffered = false } } }
// Combined in player controller if (input.justPressed('jump')) { if (this.canJump()) { this.jump() } else { jumpBuffer.bufferJump() } }
// On landing if (this.justLanded && jumpBuffer.consumeJump()) { this.jump() }
---
Name
Hitstop (Hit Freeze) System
Description
Brief pause on hit to sell impact
When
Attacks need to feel impactful
Why
Hitstop gives the brain time to register the hit; without it, combat feels floaty
Example
// HITSTOP - The most important combat feel technique // // When attack connects, BOTH attacker and target pause // Duration scales with attack power // Creates the "crunch" that sells impact // // Reference values (at 60fps): // Light attack: 3-5 frames (50-83ms) // Medium attack: 6-10 frames (100-167ms) // Heavy attack: 12-18 frames (200-300ms) // Critical/Finishing: 20-30 frames (333-500ms)
class HitstopManager { hitstopRemaining: number = 0
applyHitstop(frames: number) { // Take the larger hitstop if overlapping this.hitstopRemaining = Math.max(this.hitstopRemaining, frames) }
update(): boolean { if (this.hitstopRemaining > 0) { this.hitstopRemaining-- return true // Game is frozen } return false // Normal update } }
// Usage in game loop function gameLoop(delta) { // Check if in hitstop if (hitstopManager.update()) { // Still render, just don't update positions render() return }
// Normal update updateGame(delta) render() }
// When hit connects function onHitConfirmed(attacker, target, damage) { const hitstopFrames = calculateHitstop(damage)
// Both freeze (critical for game feel) attacker.applyHitstop(hitstopFrames) target.applyHitstop(hitstopFrames)
// Global hitstop for camera shake timing hitstopManager.applyHitstop(hitstopFrames)
// The "crunch" happens during hitstop screenShake(damage) spawnHitParticles(target.position) playHitSound(damage) }
// ADVANCED: Asymmetric hitstop // Attacker freezes less than target // Creates feeling of follow-through function applyAsymmetricHitstop(attacker, target, baseFrames) { attacker.applyHitstop(baseFrames * 0.7) // 70% target.applyHitstop(baseFrames) // 100% }
// ADVANCED: Hitstop ramping for combos // Each hit in combo adds slightly more hitstop // Creates escalating satisfaction function comboHitstop(baseFrames, comboCount) { const ramp = 1 + (comboCount 0.1) // +10% per hit const maxMultiplier = 2.0 return Math.round(baseFrames Math.min(ramp, maxMultiplier)) }
---
Name
Screen Shake for Impact
Description
Camera trauma that reinforces hit feedback
When
Attacks connect, explosions occur, heavy landings
Why
Visual shake combined with hitstop creates visceral impact
Example
// SCREEN SHAKE - The partner to hitstop // // Types of shake: // 1. TRAUMA-BASED: Decaying shake from single events // 2. CONTINUOUS: Ongoing shake for rumble/engines // 3. DIRECTIONAL: Shake in attack direction
class ScreenShake { trauma: number = 0 // 0-1, decays over time maxOffset: number = 10 // Max pixel displacement maxRotation: number = 2 // Max degrees rotation decayRate: number = 0.8 // Per-frame multiplier
// Add trauma from hit (0-1 based on damage) addTrauma(amount: number) { this.trauma = Math.min(1, this.trauma + amount) }
update() { if (this.trauma > 0) { // Quadratic falloff feels more natural const shake = this.trauma * this.trauma
// Perlin noise for smooth shake const offsetX = this.maxOffset shake noise(time 20) const offsetY = this.maxOffset shake noise(time 20 + 100) const rotation = this.maxRotation shake noise(time * 20 + 200)
camera.offset.x = offsetX camera.offset.y = offsetY camera.rotation = rotation
// Decay trauma this.trauma *= this.decayRate if (this.trauma < 0.01) this.trauma = 0 } } }
// Directional shake - punch in attack direction function directionalShake(direction: Vector2, power: number) { // Initial push in hit direction camera.offset.x += direction.x power 0.5 camera.offset.y += direction.y power 0.5
// Then normal trauma shake screenShake.addTrauma(power) }
// TRAUMA VALUES BY ATTACK TYPE const SHAKE_VALUES = { lightAttack: 0.15, mediumAttack: 0.3, heavyAttack: 0.5, criticalHit: 0.7, finisher: 1.0,
// Environmental landing: 0.1, explosion: 0.8, bossSlam: 0.9 }
// IMPORTANT: Shake during hitstop // Shake happens WHILE frozen, not after // This is when impact registers
---
Name
Invincibility Frames (I-Frames)
Description
Periods where player cannot be hit
When
Implementing dodges, rolls, backsteps
Why
I-frames reward timing and create strategic depth
Example
// I-FRAMES - The soul of defensive combat // // Dodge/roll i-frames create risk/reward: // - Too many: Dodge spam trivializes combat // - Too few: Dodging feels useless // - Timed right: Rewards mastery // // Reference values (at 60fps): // // Dark Souls roll: ~13 i-frames in 30-frame roll // Bloodborne dodge: ~10 i-frames in 26-frame quickstep // DMC5 dodge: ~8 i-frames, but very responsive // Sekiro deflect: Frame-perfect with generous window
class DodgeSystem { state: 'ready' | 'startup' | 'iframes' | 'recovery' = 'ready' stateFrame: number = 0
dodgeData = { startup: 2, // Vulnerable startup iframes: 12, // Invincible period recovery: 8, // Vulnerable recovery cooldown: 6 // Before can dodge again }
startDodge(direction: Vector2) { this.state = 'startup' this.stateFrame = 0 this.direction = direction }
update() { this.stateFrame++
switch (this.state) { case 'startup': if (this.stateFrame >= this.dodgeData.startup) { this.state = 'iframes' this.stateFrame = 0 } break
case 'iframes': if (this.stateFrame >= this.dodgeData.iframes) { this.state = 'recovery' this.stateFrame = 0 } break
case 'recovery': if (this.stateFrame >= this.dodgeData.recovery) { this.state = 'ready' } break } }
isInvincible(): boolean { return this.state === 'iframes' }
canDodge(): boolean { return this.state === 'ready' } }
// ADVANCED: Variable i-frames based on timing // Reward players who dodge INTO attacks function calculateIframes(dodgeDirection, attackDirection) { const dot = dodgeDirection.dot(attackDirection)
// Dodging into attack: More i-frames if (dot > 0.7) return 15 // Neutral dodge: Standard i-frames if (dot > -0.3) return 12 // Dodging away: Fewer i-frames return 8 }
// PARRY WINDOWS (even more demanding) // Perfect parry: 3-6 frames (50-100ms) // Generous parry: 8-12 frames (133-200ms) // // Parry is high risk, high reward // Failed parry often means taking hit
---
Name
Attack Cancel Windows
Description
When attacks can be interrupted into other actions
When
Building combo systems or responsive combat
Why
Cancels create depth through player expression and mix-ups
Example
// CANCEL WINDOWS - The grammar of combo systems // // Cancel types (in order of commitment): // 1. Normal -> Normal (attack chains) // 2. Normal -> Special (combo extensions) // 3. Any -> Dodge (escape option) // 4. Any -> Super (resource-gated escape) // // Fighting game notation: // Startup -> Active -> Recovery // Cancels happen during Active or early Recovery
class AttackData { frames = { startup: 5, active: 3, recovery: 12 }
cancelWindows = { // Frame ranges where cancels are allowed normalCancel: { start: 8, end: 16 }, // Cancel into next normal specialCancel: { start: 6, end: 18 }, // Cancel into special jumpCancel: { start: 10, end: 20 }, // Cancel into jump dodgeCancel: { start: 5, end: 15 } // Cancel into dodge }
// On-hit only cancels (reward landing the hit) onHitCancels = { launcher: { start: 8, end: 12 }, // Only if hit confirmed finisher: { start: 10, end: 14 } } }
class ComboSystem { currentAttack: AttackData | null = null attackFrame: number = 0 hitConfirmed: boolean = false
canCancelInto(cancelType: string): boolean { if (!this.currentAttack) return false
const window = this.currentAttack.cancelWindows[cancelType] if (!window) return false
return this.attackFrame >= window.start && this.attackFrame <= window.end }
canCancelOnHit(cancelType: string): boolean { if (!this.hitConfirmed) return false
const window = this.currentAttack.onHitCancels[cancelType] if (!window) return false
return this.attackFrame >= window.start && this.attackFrame <= window.end } }
// DMC-STYLE COMBO SYSTEM // Chain hierarchy: Normal < Special < Super // Each level cancels the one below // Creates the "triangle" of options
// SOULS-LIKE COMMITMENT // Minimal cancels - recovery is punishable // Only dodge cancel, and it costs stamina // Creates deliberate, tactical combat
// PLATINUM STYLE // Liberal cancels but with "just frame" bonuses // Dodge Offset: Hold attack, dodge, release to continue combo // Creates expression through execution
---
Name
Enemy Archetype System
Description
Design enemies with clear combat roles
When
Populating a game with varied combat encounters
Why
Archetypes create encounter variety without exponential design work
Example
// THE FUNDAMENTAL ENEMY ARCHETYPES // // 1. FODDER/GRUNT // - Low HP, low damage // - Simple attack patterns (1-2 attacks) // - Short telegraphs // - Purpose: Warm-up, combo building, group pressure
const GRUNT = { hp: 30, damage: 10, attacks: [ { name: 'slash', startup: 15, active: 5, recovery: 20 } ], behavior: 'approach_and_swing', stagger: 'on_any_hit' }
// 2. RANGED // - Low-medium HP // - Attacks from distance // - Forces player movement // - Purpose: Area denial, pressure during melee
const RANGED = { hp: 25, damage: 15, preferredDistance: 10, attacks: [ { name: 'projectile', startup: 20, active: 1, recovery: 30 } ], behavior: 'maintain_distance_and_fire', stagger: 'on_any_hit' }
// 3. TANK/ARMORED // - High HP, high poise // - Slow but dangerous attacks // - Long recovery windows // - Purpose: Teaches patience, punish timing
const TANK = { hp: 150, damage: 40, poise: 50, // Takes 50 damage before stagger attacks: [ { name: 'overhead_slam', startup: 40, active: 8, recovery: 60 } ], behavior: 'approach_slowly_attack_when_close', stagger: 'only_when_poise_broken' }
// 4. AGILE/ASSASSIN // - Low HP, high damage // - Fast attacks, short openings // - Dodges/teleports // - Purpose: Teaches aggression, don't let them breathe
const ASSASSIN = { hp: 40, damage: 35, attacks: [ { name: 'quick_slash', startup: 8, active: 3, recovery: 15 }, { name: 'backstab', startup: 25, active: 5, recovery: 10 } ], behavior: 'circle_and_strike_dodge_often', stagger: 'on_any_hit_brief' }
// 5. ELITE/MINIBOSS // - High HP, varied moveset // - Multiple attack phases // - Tests everything player has learned // - Purpose: Skill check, gatekeeper
const ELITE = { hp: 300, phases: [ { threshold: 1.0, moveset: ['combo_a', 'ranged_attack'] }, { threshold: 0.5, moveset: ['combo_a', 'combo_b', 'grab'] }, { threshold: 0.25, moveset: ['enraged_combo', 'aoe_attack'] } ] }
// ENCOUNTER DESIGN FORMULA // Start: 2-3 grunts (warmup) // Build: Add ranged enemy (positioning) // Tension: Add tank OR assassin (focus target) // Peak: Mixed group OR elite // Breather: Few grunts, resources
---
Name
Attack Telegraph System
Description
Visual and audio cues that warn of incoming attacks
When
Designing enemy attacks players must react to
Why
Telegraphs make combat about skill, not memorization or luck
Example
// TELEGRAPH HIERARCHY // Every attack needs multiple layers of warning // // 1. STANCE/POSTURE (earliest warning) // 2. WIND-UP ANIMATION (primary tell) // 3. VFX INDICATORS (reinforcement) // 4. AUDIO CUE (accessibility, off-screen)
class AttackTelegraph { // Telegraph timing relative to attack phases = { stance: -30, // 30 frames before wind-up windUp: -20, // 20 frames of wind-up animation vfxWarn: -15, // VFX starts 15 frames before hit audioWarn: -12, // Audio cue 12 frames before hit attack: 0 // Hit frame }
// VFX indicators by attack type vfxIndicators = { melee: 'weapon_glow', slam: 'ground_target_circle', projectile: 'charge_particles', grab: 'grab_range_indicator', aoe: 'danger_zone_fill' } }
// TELEGRAPH DURATION BY DIFFICULTY // The same attack can feel fair or unfair based on telegraph time
const TELEGRAPH_SCALING = { // Human reaction time: ~200-300ms (12-18 frames at 60fps)
easy: { fastAttack: 24, // 400ms - comfortable reaction mediumAttack: 36, // 600ms - leisurely heavyAttack: 60 // 1000ms - obvious }, normal: { fastAttack: 18, // 300ms - reactable for most mediumAttack: 28, // 467ms - comfortable heavyAttack: 45 // 750ms - clear }, hard: { fastAttack: 12, // 200ms - at reaction limit mediumAttack: 20, // 333ms - requires attention heavyAttack: 35 // 583ms - standard }, expert: { fastAttack: 8, // 133ms - prediction required mediumAttack: 15, // 250ms - tight reaction heavyAttack: 25 // 417ms - punishing } }
// ATTACK TELLS - What makes a good telegraph // // GOOD TELEGRAPH: // - Distinct silhouette from other animations // - Clear direction of incoming attack // - Consistent timing (same wind-up = same timing) // - Audio reinforcement // // BAD TELEGRAPH: // - Looks similar to non-threatening animation // - Variable timing (sometimes fast, sometimes slow) // - No audio (off-screen attacks feel unfair) // - Too subtle (only visible if you already know it)
// FROM SOFTWARE EXAMPLE: // Margit's dagger throw has explicit wind-up // Changes stance, pulls arm back, pauses, throws // Even first-time players can see it coming // But timing is tight enough to punish bad dodges
---
Name
Damage Feedback Hierarchy
Description
Layered feedback that communicates damage magnitude
When
Hits need to communicate impact level
Why
Players need to understand if attacks are effective
Example
// FEEDBACK LAYERS (all should scale with damage) // // 1. HITSTOP (timing) // 2. SCREEN SHAKE (camera) // 3. HIT VFX (particles) // 4. HIT SFX (audio) // 5. ANIMATION (target reaction) // 6. HAPTICS (controller rumble) // 7. UI (damage numbers, health bar)
class DamageFeedbackSystem { applyFeedback(damage: number, isCritical: boolean, position: Vector2) { // Normalize damage to 0-1 for scaling const intensity = Math.min(damage / 100, 1)
// 1. HITSTOP - Most important const hitstopFrames = this.calculateHitstop(intensity, isCritical) hitstopManager.apply(hitstopFrames)
// 2. SCREEN SHAKE screenShake.addTrauma(intensity * (isCritical ? 1.5 : 1))
// 3. HIT VFX const vfxScale = 0.5 + intensity * 0.5 const vfxType = isCritical ? 'critical_hit' : 'normal_hit' vfxSystem.spawn(vfxType, position, vfxScale)
// 4. HIT SFX const sfxType = this.selectHitSound(intensity, isCritical) audioManager.play(sfxType, position)
// 5. HAPTICS const rumbleIntensity = intensity * (isCritical ? 1.0 : 0.6) haptics.rumble(rumbleIntensity, hitstopFrames / 60)
// 6. UI if (showDamageNumbers) { ui.spawnDamageNumber(damage, position, isCritical) } }
calculateHitstop(intensity: number, critical: boolean): number { // Light hit: 3-5 frames // Heavy hit: 10-15 frames // Critical: 1.5x multiplier const base = 3 + Math.round(intensity 12) return critical ? Math.round(base 1.5) : base }
selectHitSound(intensity: number, critical: boolean): string { if (critical) return 'hit_critical' if (intensity > 0.7) return 'hit_heavy' if (intensity > 0.3) return 'hit_medium' return 'hit_light' } }
// CRITICAL HIT SPECIAL TREATMENT // Critical hits should feel EXCEPTIONAL: // - Longer hitstop (1.5-2x) // - Unique audio sting // - Distinct VFX (different color, more particles) // - Slow-motion optional (for finishers) // - UI fanfare (flash, scale)
// OVERKILL FEEDBACK // When enemy dies, scale feedback to remaining damage // Makes "just enough" feel different from "overwhelming power"
function applyKillingBlow(target, damage, overkillAmount) { const overkillRatio = overkillAmount / target.maxHp
// Overkill = bigger explosion if (overkillRatio > 0.5) { vfx.spawn('death_explosion_large', target.position) screenShake.addTrauma(0.8) timeScale.pulse(0.1, 200) // Slow-mo pulse } else { vfx.spawn('death_explosion_normal', target.position) screenShake.addTrauma(0.4) } }
---
Name
Recovery and Punishment Windows
Description
Frame-data driven openings after attacks
When
Combat needs risk/reward depth
Why
Recovery windows create strategy; attacks have consequences
Example
// RECOVERY FRAMES - Where strategy lives // // Every attack should have a period of vulnerability after // This is the "cost" of swinging // // Formula: Power = Startup + Active + Recovery // Stronger attacks = longer total commitment
class AttackFrameData { // Light attack: Quick but weak lightAttack = { startup: 5, // Fast to come out active: 3, // Brief hitbox recovery: 10, // Short vulnerability total: 18, // 300ms commitment onBlock: -4 // Slight disadvantage if blocked }
// Heavy attack: Strong but risky heavyAttack = { startup: 15, // Telegraphed active: 5, // Extended hitbox recovery: 25, // Long vulnerability total: 45, // 750ms commitment onBlock: -15 // Very punishable if blocked }
// Special attack: High risk/reward specialAttack = { startup: 20, active: 8, recovery: 30, // Whiff = death sentence total: 58, // Almost 1 second onBlock: -20 // Free punish if blocked } }
// FRAME ADVANTAGE/DISADVANTAGE // The currency of fighting game strategy // // Positive (+) = You recover first, can act // Neutral (0) = Equal, reset to neutral // Negative (-) = Opponent recovers first // // Example: Your move is -5 on block // Enemy blocks, they can act 5 frames before you // If they have a 5-frame startup attack = guaranteed
function calculateFrameAdvantage( attackerRecovery: number, targetBlockstun: number ): number { // Positive = attacker advantage // Negative = defender advantage return targetBlockstun - attackerRecovery }
// ENEMY RECOVERY WINDOWS // This is where bosses feel fair or unfair // // Fair boss: Long recovery after big attacks // Unfair boss: Instantly can attack again
const BOSS_ATTACK_EXAMPLE = { name: 'overhead_slam', startup: 45, // Long wind-up, very readable active: 10, // Extended danger zone recovery: 60, // ONE SECOND of vulnerability // This is the "hit me" window // Player learns: Dodge, then punish
punishWindow: { frames: 60, // 1 second playerAttackStartup: 15, // Player's attack maxPunishHits: 2 // Can get 2 hits in safely } }
// RULE OF THUMB: // Recovery frames should be >= player's fastest punish option // Otherwise the "opening" isn't really an opening
---
Name
Stamina and Resource Management
Description
Action economy that creates strategic decisions
When
Combat needs pacing and decision-making
Why
Resources prevent spam and create risk/reward
Example
// STAMINA SYSTEM (Souls-like) // // Every action costs stamina // Creates strategic decisions: // - Do I attack or save stamina for dodge? // - Do I block this or roll? // - Can I afford another swing?
class StaminaSystem { current: number = 100 max: number = 100
costs = { lightAttack: 15, heavyAttack: 30, roll: 20, sprint: 5, // Per second block: 0 // But regen paused while blocking }
recovery = { rate: 30, // Per second delayAfterAction: 0.5, // Seconds before regen starts blockingPenalty: 0.5, // 50% regen while blocking emptyPenalty: 1.5 // Extra delay when depleted }
lastActionTime: number = 0
canAfford(action: string): boolean { return this.current >= this.costs[action] }
spend(action: string): boolean { if (!this.canAfford(action)) return false
this.current -= this.costs[action] this.lastActionTime = time.now()
return true }
update(delta: number) { // Check regen delay const timeSinceAction = time.now() - this.lastActionTime const delay = this.current <= 0 ? this.recovery.emptyPenalty : this.recovery.delayAfterAction
if (timeSinceAction < delay) return
// Apply regen let regenRate = this.recovery.rate if (this.isBlocking) regenRate *= this.recovery.blockingPenalty
this.current = Math.min(this.max, this.current + regenRate * delta) } }
// BLOCK STAMINA (different from action stamina) // Taking hits while blocking drains stamina // Block broken = staggered, vulnerable
function onBlockedAttack(damage: number) { const staminaDrain = damage * 0.5 stamina.current -= staminaDrain
if (stamina.current <= 0) { // Guard break! player.stagger(60) // 1 second stun vfx.spawn('guard_break') sfx.play('guard_break') } }
// POISE/POSTURE SYSTEM (Sekiro-style) // Taking hits builds "posture damage" // Full posture = vulnerable to critical // Creates aggressive play incentive
class PostureSystem { current: number = 0 // Starts empty max: number = 100 recoveryRate: number = 5 // Per second
takeDamage(damage: number, isBlocked: boolean) { // Blocked attacks deal more posture damage const multiplier = isBlocked ? 1.5 : 1.0 this.current += damage * multiplier
if (this.current >= this.max) { this.triggerPostureBreak() } }
triggerPostureBreak() { // Open for deathblow/critical owner.enterVulnerableState(120) // 2 seconds vfx.spawn('posture_break') sfx.play('posture_break') this.current = 0 } }
Anti-Patterns
---
Name
Invisible Hitboxes
Description
Hitboxes that don't match visual attacks
Why
Players die to attacks that visually missed; feels unfair and random
Instead
Make hitboxes LARGER than visuals, not smaller. If an attack looks like it should hit, it should hit. Test with hitbox visualization enabled.
// Debug visualization is mandatory during development function renderHitboxDebug() { for (const hitbox of activeHitboxes) { drawWireframe(hitbox, COLOR_RED) } for (const hurtbox of allHurtboxes) { drawWireframe(hurtbox, COLOR_GREEN) } }
---
Name
Unreadable Attack Tells
Description
Enemy attacks with no clear warning
Why
Players can't react to what they can't see; memorization replaces skill
Instead
Every attack needs telegraph time >= human reaction time (~250ms). Fast attacks are fine IF telegraphed by stance/behavior. Add audio cues for attacks - accessibility and fairness.
Rule of thumb: If playtesters say "I didn't see that coming" more than 10% of the time, the telegraph is too subtle.
---
Name
No Recovery Windows
Description
Enemies that can attack again immediately after attacking
Why
No punishment opportunity means no strategy; just dodge forever
Instead
Every attack should have a clear window where the enemy is vulnerable. Recovery >= player's fastest punish. If unsure, make recovery too long then tune shorter.
// Boss attack formula: // Big wind-up (readable) + Extended recovery (punishable) = Fair // Quick attack + Instant followup = Frustrating
---
Name
Damage Sponges
Description
Enemies with massive HP but simple patterns
Why
Long fights without variety are boring; repetition without depth
Instead
Reduce HP, add phases or new attacks. If a fight lasts > 3 minutes, it needs phase transitions. Interesting fights are about adaptation, not endurance.
// Instead of: bossHP = 5000 attackPattern = [attack1, attack2, attack1, attack2...]
// Do: bossHP = 2000 phases = [ { threshold: 1.0, attacks: [attack1, attack2] }, { threshold: 0.5, attacks: [attack1, attack2, attack3, newMechanic] }, { threshold: 0.25, attacks: [enragedCombo, desperationMove] } ]
---
Name
Input Delay Ignoring
Description
Not accounting for input-to-action delay
Why
Combat feels sluggish; players blame the game, not themselves
Instead
Measure total input latency: Input -> Action visible on screen. Target: < 100ms for responsive games, < 66ms for fighting games. Compensate for platform (TV game mode, wireless controllers).
// Common sources of delay: // - Controller polling (8-16ms wireless) // - Input processing (1 frame = 16.67ms) // - Animation blend time (variable) // - Display lag (16-60ms on TVs) // // Total can easily reach 100-200ms if not careful
---
Name
Cancel Everything Always
Description
Every attack can cancel into any other action at any time
Why
No commitment means no risk; combat becomes mash-fest
Instead
Cancels should be strategic choices, not universal escapes. Design cancel hierarchies: Normal < Special < Super. Some attacks SHOULD be committal - that's where reads happen.
// Good cancel design: // Early frames: Can cancel into dodge (escape option) // Active frames: Committed (risk) // Late recovery: Can cancel into combo followup (reward for hit)
---
Name
Inconsistent Frame Data
Description
Same-looking attacks with different timings
Why
Players can't build reliable muscle memory; reactions feel random
Instead
Visual similarity should mean timing similarity. If two attacks look the same, they should have same frame data. Exceptions must have clear visual distinction.
// Enemy has "quick slash" and "delayed slash" // BAD: Both use same animation at different speeds // GOOD: Delayed slash has distinct wind-up pose and effect
---
Name
Perfect Play Required
Description
Combat that only works if player never makes mistakes
Why
Most players will give up; only 1% finish your game
Instead
Design for "good enough" play, not perfect play. Recovery from mistakes should be possible (healing, distance, etc). Difficulty comes from consistency over time, not single execution tests.
// Dark Souls works because: // Individual mistakes are recoverable (heal, back off) // Difficulty is cumulative (resource management over time) // Victory requires consistency, not perfection
Combat Design - Sharp Edges
Hitbox Visual Mismatch
Id
hitbox-visual-mismatch
Summary
Hitboxes that don't match visual attacks
Severity
critical
Situation
Attack visuals show a wide sweep but hitbox is tiny, or vice versa
Why
This is the #1 source of player frustration in action games. When an attack that visually connects deals no damage (or an attack that visually missed deals damage), players feel cheated. They blame the game, not themselves, and rightfully so.
From Software, Capcom, and Platinum all intentionally make hitboxes LARGER than weapon meshes by 20-40%. The philosophy: if it looks like a hit, it should be a hit. Players aim at the center of targets, not the exact edge of their weapon swing.
Solution
1. Always visualize hitboxes during development 2. Make offensive hitboxes slightly larger than visuals 3. Make defensive hurtboxes slightly smaller than character model 4. Test with hitbox visualization OFF to validate feel 5. Get fresh playtesters - developers become blind to mismatches
// Debug visualization is MANDATORY class HitboxDebugger { static enabled = true // Toggle with debug key
static render() { if (!this.enabled) return
// Red = attack hitboxes (danger to enemies) for (const hitbox of activeHitboxes) { drawWireframe(hitbox, COLOR_RED, ALPHA_50) }
// Green = hurtboxes (can receive damage) for (const entity of allEntities) { for (const hurtbox of entity.hurtboxes) { drawWireframe(hurtbox, COLOR_GREEN, ALPHA_30) } } } }
Symptoms
- Players complain attacks "phase through" enemies
- Players die to attacks that "clearly missed"
- Inconsistent damage dealing at edges of attacks
- "Hitbox porn" or "hitbox gore" comments on forums
Detection Pattern
Input Latency Stack
Id
input-latency-stack
Summary
Accumulated input delay making combat unresponsive
Severity
critical
Situation
Multiple sources of delay compound to make inputs feel sluggish
Why
Input latency is death by a thousand cuts. Each layer seems acceptable:
- Controller polling: 8-16ms
- Engine input processing: 1 frame (16.67ms)
- Animation blend-in: 5-10 frames (83-167ms)
- Game logic delay: variable
- Display lag: 16-60ms on TVs
Combined, you're at 150-300ms before the player sees their action. Human reaction time is ~200-300ms. If input latency approaches reaction time, combat becomes prediction-only, not reaction-based.
Fighting games target <4 frames (~67ms) of input lag. Action games should target <100ms total.
Solution
1. Measure total input-to-screen latency with high-speed camera 2. Minimize animation blend times for attacks (instant or 1-2 frames) 3. Use input buffering to mask remaining latency 4. Process input as early as possible in game loop 5. Test with wired controller on gaming monitor for baseline
// Measure and log input latency class InputLatencyProfiler { inputTime: number actionTime: number
onInput(action: string) { this.inputTime = performance.now() }
onActionStart() { this.actionTime = performance.now() const latency = this.actionTime - this.inputTime console.log(Input latency: ${latency.toFixed(1)}ms)
if (latency > 100) { console.warn('INPUT LATENCY TOO HIGH') } } }
// Reduce animation blend time for attacks function startAttack(attackAnim: string) { // BAD: Smooth blend (feels sluggish) animator.crossFade(attackAnim, 0.2)
// GOOD: Near-instant transition (responsive) animator.crossFade(attackAnim, 0.033) // 2 frames max }
Symptoms
- Combat feels "floaty" or "sluggish"
- Players say inputs are "eaten"
- Attacks come out later than expected
- Dodge timing feels inconsistent
Detection Pattern
crossFade\s\([^,]+,\s0\.[2-9]|transitionDuration\s[=:]\s0\.[2-9]
No Input Buffering
Id
no-input-buffering
Summary
Strict input timing without buffering
Severity
critical
Situation
Inputs only count if pressed at exact frame, with no forgiveness
Why
Human timing is imprecise. Players press attack 2-5 frames "early" all the time, expecting the action to queue. Without buffering, inputs feel dropped. Players mash because single presses don't seem to register.
Every major action game uses input buffering:
- Street Fighter: 3-10 frame buffer
- Dark Souls: ~10 frame buffer
- Devil May Cry: ~8 frame buffer
Without buffering, players must hit exact frames. A 6-frame window at 60fps is only 100ms - tighter than average human reaction time.
Solution
// Input buffer implementation class InputBuffer { buffer: Array<{action: string, frame: number}> = [] bufferWindow: number = 10 // Frames to hold input
queueInput(action: string) { this.buffer.push({ action, frame: currentFrame }) }
hasBufferedInput(action: string): boolean { const now = currentFrame return this.buffer.some( b => b.action === action && (now - b.frame) <= this.bufferWindow ) }
consumeInput(action: string): boolean { const now = currentFrame const index = this.buffer.findIndex( b => b.action === action && (now - b.frame) <= this.bufferWindow )
if (index !== -1) { this.buffer.splice(index, 1) return true } return false }
update() { // Clear expired inputs const now = currentFrame this.buffer = this.buffer.filter( b => (now - b.frame) <= this.bufferWindow ) } }
// CRITICAL: Check buffer when action becomes possible function onRecoveryEnd() { // Player's buffered attack now executes if (inputBuffer.consumeInput('attack')) { startNextAttack() } }
Symptoms
- Players feel inputs are "dropped" or "eaten"
- Mashing feels necessary for reliability
- Combos only work when mashing
- Single button presses feel unreliable
Detection Pattern
justPressed|isPressed\s\(\s["'][^"']+["']\s\)\s(?!.*buffer)
No Coyote Time
Id
no-coyote-time
Summary
Requiring ground contact for jumping in platforming combat
Severity
high
Situation
Player pressed jump 1-3 frames after leaving ledge and nothing happens
Why
When players run off a ledge, they press jump thinking they're still grounded. Without coyote time, nothing happens. The player's mental model (I pressed jump while on platform) differs from game state (you left ground 2 frames ago).
Named after Wile E. Coyote running off cliffs and hanging in mid-air.
Nearly every platformer and action game implements this:
- Celeste: 5 frames coyote time
- Hollow Knight: ~6 frames
- Super Meat Boy: ~4 frames
Solution
class PlatformingController { lastGroundedFrame: number = 0 coyoteFrames: number = 6 // Adjust per game feel
update() { if (this.isOnGround()) { this.lastGroundedFrame = currentFrame }
if (input.justPressed('jump')) { if (this.canCoyoteJump()) { this.jump() } else { // Buffer the jump for landing jumpBuffer.buffer() } } }
canCoyoteJump(): boolean { // Actually grounded if (this.isOnGround()) return true
// Within coyote window const framesSinceGrounded = currentFrame - this.lastGroundedFrame return framesSinceGrounded <= this.coyoteFrames }
isOnGround(): boolean { return this.groundCheck.isColliding() } }
// IMPORTANT: Coyote time should NOT apply when jumping // Only when walking/falling off ledge // Otherwise: Jump -> coyote time -> double jump exploit
function onLeaveGround(reason: 'jump' | 'fall' | 'knockback') { if (reason === 'jump') { // Jumped intentionally - no coyote time lastGroundedFrame = -Infinity } else { // Fell off or was knocked off - grant coyote time lastGroundedFrame = currentFrame } }
Symptoms
- Players "miss" jumps at ledge edges
- Jumping while running feels unreliable
- Players slow down before jumping (compensating)
- Complaints about "floaty" or "unresponsive" jumping
Detection Pattern
No Recovery Windows
Id
no-recovery-windows
Summary
Enemies with no punishable recovery after attacks
Severity
critical
Situation
Boss finishes attack and can immediately attack again
Why
Recovery windows are where combat strategy lives. If enemies have no vulnerability after attacking, combat becomes:
- Wait for attack
- Dodge
- Wait for attack
- Dodge
- (Forever, until you find the "trick")
This is tedious, not challenging. Souls-like bosses work because:
- Big attacks have long recoveries
- Small attacks have short recoveries
- Players learn attack patterns = recovery patterns
The "dance" of combat is: Evade -> Punish -> Reset -> Repeat Without recovery windows, there's no Punish phase.
Solution
// Every attack should have explicit recovery class EnemyAttack { phases = { startup: 30, // Wind-up (telegraphing) active: 10, // Danger window recovery: 45, // VULNERABLE window }
// Recovery should allow player's fastest punish // If player's light attack is 15 frames startup: // Recovery must be >= 15 frames for a 1-hit punish // >= 30 frames for a 2-hit punish }
// Scale recovery to attack power function calculateRecovery(attackPower: number): number { // Big attack = big recovery // Small attack = small recovery
const base = 20 // Minimum recovery const scale = 1.5 // Frames per power unit
return Math.round(base + attackPower * scale) }
// Boss example const BOSS_OVERHEAD_SLAM = { damage: 80, startup: 45, // Long wind-up = very readable active: 15, // Extended danger zone recovery: 60, // ONE FULL SECOND of vulnerability // Player should recognize: "Big swing = long recovery = hit him now" }
// THE RULE OF THUMB: // If playtesters ask "when am I supposed to attack?" // Your recovery windows are too short or unclear
Symptoms
- Players only attack during scripted openings
- Combat feels like "dodge forever until cutscene"
- Players feel they "can't find an opening"
- Fights feel unfair despite being technically beatable
Detection Pattern
Unreadable Attack Tells
Id
unreadable-attack-tells
Summary
Enemy attacks with insufficient or unclear telegraphing
Severity
critical
Situation
Player takes damage without understanding what attack is coming
Why
Combat should test reaction speed and pattern recognition, not memorization of invisible attacks. If players can't see an attack coming, they can't react - they can only memorize or get lucky.
Good telegraph has:
- Distinct visual pose/animation
- Clear direction indication
- Consistent timing (same wind-up = same attack)
- Audio reinforcement
Bad telegraph:
- Looks like idle or other non-attack animation
- Starts with no warning, damage on frame 1
- Variable timing for same-looking animation
- Silent attacks (especially off-screen)
Solution
// Telegraph timing budget (at 60fps) // Human reaction time: 200-300ms = 12-18 frames
const TELEGRAPH_MINIMUMS = { // Fast attacks: At limit of reaction fast: 12, // 200ms - skilled players can react
// Medium attacks: Comfortable reaction medium: 24, // 400ms - most players can react
// Heavy attacks: Obvious wind-up heavy: 36, // 600ms - impossible to miss
// Grab/special: Extra telegraph (high punishment) grab: 30, // 500ms - grabs should be very readable }
// Layer multiple telegraph signals class AttackTelegraph { startTelegraph(attack: Attack) { // Layer 1: Animation (primary) animator.play(attack.windupAnim)
// Layer 2: Visual effect (reinforcement) vfx.spawn(attack.chargeEffect, this.position)
// Layer 3: Audio (accessibility + off-screen) audio.play(attack.telegraphSound, { priority: 'high', spatial: true })
// Layer 4: UI indicator for key attacks if (attack.showIndicator) { ui.showAttackIndicator(attack.direction) } } }
// TEST: Record new playtesters reacting to attacks // If they get hit AND say "I didn't see that": // - Telegraph too short, or // - Telegraph too similar to non-attack, or // - Telegraph not visually distinct enough
Symptoms
- "That came out of nowhere" comments
- Players die repeatedly to same attack without learning
- Death feels random, not earned
- Players only win through memorization, not reaction
Detection Pattern
Damage Sponge Enemies
Id
damage-sponge-enemies
Summary
Enemies with excessive HP and repetitive patterns
Severity
high
Situation
Fight lasts 10+ minutes of repeating the same dodge-attack cycle
Why
High HP without pattern variety creates tedium, not challenge. Players master the pattern in the first minute, then spend 9 more minutes proving they can repeat it perfectly without getting bored.
Long fights work when they have:
- Phase transitions (new patterns to learn)
- Escalation (increasing pressure)
- Variation (randomized attack selection)
Long fights fail when they're just:
- More HP to chew through
- Same pattern, longer execution
Solution
// Instead of high HP, use phases const BOSS_CONFIG = { // BAD: Pure HP sponge // hp: 10000, // pattern: [attack1, attack2] // Repeats forever
// GOOD: Phased encounter totalHp: 4000, phases: [ { hpThreshold: 1.0, // Phase 1: 100-70% HP attacks: [basicSlash, chargeAttack], behavior: 'cautious', telegraphMultiplier: 1.2 // Longer telegraphs in phase 1 }, { hpThreshold: 0.7, // Phase 2: 70-40% HP attacks: [basicSlash, chargeAttack, newCombo, areaAttack], behavior: 'aggressive', telegraphMultiplier: 1.0 }, { hpThreshold: 0.4, // Phase 3: 40-0% HP attacks: [enhancedSlash, chargeCombo, desperationAttack], behavior: 'relentless', telegraphMultiplier: 0.9 // Slightly faster } ] }
// Rule of thumb: // If fight > 3 minutes, it NEEDS phase transitions // If fight > 5 minutes, it's probably too long
// Calculate expected fight duration function estimateFightDuration(bossHp, playerDps, mistakeRate) { const effectiveDps = playerDps (1 - mistakeRate 0.5) const duration = bossHp / effectiveDps
if (duration > 180) { // 3 minutes console.warn('Boss fight may be too long. Consider reducing HP or adding phases.') }
return duration }
Symptoms
- Players win but feel bored, not triumphant
- "Took forever" complaints
- Players describe fight as "tedious" not "challenging"
- No memorable moments, just repetition
Detection Pattern
Inconsistent Frame Data
Id
inconsistent-frame-data
Summary
Same-looking attacks with different timings
Severity
high
Situation
Enemy has two attacks that look similar but have different wind-up times
Why
Players build muscle memory for attack timings. If a wind-up animation sometimes results in a 20-frame attack and sometimes a 30-frame attack, players can't build reliable reactions.
This feels like "sometimes my dodge works, sometimes it doesn't" - which players interpret as broken or random.
Variation in attack SELECTION is good. Variation in attack TIMING (for same visual) is bad.
Solution
// RULE: Visual similarity = timing similarity
// BAD: Same animation, different speeds const overhead_slow = { anim: 'overhead', speed: 0.8 } const overhead_fast = { anim: 'overhead', speed: 1.2 } // Players can't tell which is coming
// GOOD: Different animations for different timings const overhead_slow = { anim: 'overhead_charged', // Distinct pose chargeEffect: true, // Visual indicator frameData: { startup: 40 } } const overhead_fast = { anim: 'overhead_quick', // Different animation chargeEffect: false, frameData: { startup: 20 } }
// If you MUST have variable timing: // 1. Make it visually obvious (charge-up effect) // 2. Make charged version different pose // 3. Audio cue for charge state
class ChargedAttack { update() { this.chargeTime += delta
// Visual feedback of charge level if (this.chargeTime > HALF_CHARGE) { vfx.setChargeLevel(this.chargeEffect, 0.5) audio.play('charge_mid') } if (this.chargeTime > FULL_CHARGE) { vfx.setChargeLevel(this.chargeEffect, 1.0) audio.play('charge_full') // Now player KNOWS this is the slow version } } }
Symptoms
- "I swear I timed that right" complaints
- Dodge timing feels inconsistent
- Players can't explain why they got hit
- Same attacks feel different difficulty each time
Detection Pattern
animationSpeed\s=|playbackSpeed\s[=:]\s*[^1]
Perfect Play Required
Id
perfect-play-required
Summary
Combat that requires flawless execution to survive
Severity
high
Situation
Single mistake leads to death or unrecoverable state
Why
Humans make mistakes. If your combat requires 100+ perfect decisions in a row, only 0.1% of players will ever succeed. The rest will quit.
Challenge should come from:
- Consistency over time (resource management)
- Pattern adaptation (phase transitions)
- Strategic choices (risk vs reward)
NOT from:
- Single execution tests with death penalty
- Perfect combos required for damage
- Zero margin for error
Solution
// Build in recovery mechanics
// 1. Health recovery between encounters // Let players reset to full or near-full
// 2. Mistake recovery during combat class CombatRecovery { onPlayerHit(damage) { // Invincibility after hit (can't be comboed) player.grantIframes(30) // 0.5 seconds
// Knockback creates distance (can heal/reset) player.applyKnockback(hitDirection, 200)
// Show health clearly (awareness) ui.flashHealthBar()
// Audio warning at low health if (player.hp < player.maxHp * 0.3) { audio.play('low_health_warning') ui.showLowHealthVignette() } } }
// 3. Consumable recovery (player agency) // Estus Flask model: Limited heals per attempt // Player chooses WHEN to use recovery resource
// 4. Difficulty from cumulative challenge // Boss does 20% of your HP per hit // You can take 4-5 hits before dying // Challenge: Can you avoid most hits? // NOT: Can you avoid ALL hits?
// BAD: One-shot attacks const badBoss = { damage: 100, // Equals player HP // One mistake = restart }
// GOOD: Proportional damage const goodBoss = { lightAttack: { damage: 15 }, // 6-7 hits to kill heavyAttack: { damage: 30 }, // 3-4 hits to kill specialAttack: { damage: 50 } // 2 hits to kill, but very telegraphed }
Symptoms
- High player frustration despite "fair" mechanics
- Low completion rates
- Players describe game as "unfair" even when it's consistent
- "I died to one tiny mistake" complaints
Detection Pattern
Animation Priority Over Responsiveness
Id
animation-priority-over-responsiveness
Summary
Long animation blend times making combat feel delayed
Severity
high
Situation
Attack animations use slow crossfades, making inputs feel delayed
Why
Animators naturally want smooth transitions. But in combat, smoothness is the enemy of responsiveness. A 200ms crossfade feels like 200ms of input delay.
Fighting games use near-instant transitions (1-2 frames) for attacks. The "pop" feels responsive even if it's not as smooth.
Combat animations should prioritize: 1. Responsiveness (instant start) 2. Readability (clear poses) 3. Smoothness (distant third)
Solution
// Attack transitions should be near-instant
// BAD: Smooth blend (feels sluggish) function startAttack(anim: string) { animator.crossFade(anim, 0.15) // 150ms blend = 150ms delay feel }
// GOOD: Snap transition (responsive) function startAttack(anim: string) { animator.crossFade(anim, 0.033) // 33ms = 2 frames, barely noticeable }
// BETTER: Instant with pose matching function startAttack(anim: string) { // Find frame in attack anim that best matches current pose const matchFrame = findBestPoseMatch( animator.currentPose, attackAnim.poses ) animator.play(anim, normalizedTime: matchFrame) }
// For recovery TO idle, longer blend is OK // Combat is over, smoothness matters more function endAttack() { if (this.comboInput) { // Combo continuation: Stay snappy animator.crossFade(nextAttackAnim, 0.033) } else { // Return to idle: Can be smooth animator.crossFade(idleAnim, 0.2) } }
// Animation priorities by state: const BLEND_TIMES = { // Combat (responsive) idleToAttack: 0.033, // 2 frames attackToAttack: 0.033, // 2 frames anyToDodge: 0.033, // 2 frames
// Recovery (smooth is ok) attackToIdle: 0.15, // 9 frames dodgeToIdle: 0.1, // 6 frames
// Non-combat idleToWalk: 0.2, walkToRun: 0.15 }
Symptoms
- Attacks feel "delayed" or "sluggish"
- Players say game "doesn't respond to inputs"
- Combat feels "floaty" despite good frame data
- Animations look smooth but feel wrong
Detection Pattern
crossFade\s\([^,]+,\s0\.[1-9]|blendTime\s[=:]\s0\.[1-9]
Cancel Everything Always
Id
cancel-everything-always
Summary
Every action can cancel into any other action at any time
Severity
medium
Situation
Player can always dodge, always interrupt, never commits
Why
Commitment creates depth. If every action can be canceled into anything, there's no risk to swinging. Players just mash attack and cancel to dodge if threatened.
Strategy comes from commitment:
- "Can I finish this combo or will I get punished?"
- "Is this opening big enough for a heavy attack?"
- "Should I use the safe option or the risky one?"
Without commitment, there are no decisions - just reactions.
Solution
// Design cancel hierarchies
// Souls-like (high commitment) const SOULS_CANCELS = { idle: ['attack', 'roll', 'block', 'run'], attackStartup: ['roll'], // Can cancel out with resource attackActive: [], // Fully committed attackRecovery: ['roll'], // Can cancel late roll: [], // Committed during roll }
// Character Action (medium commitment) const ACTION_CANCELS = { idle: ['attack', 'dodge', 'special', 'jump'], attackStartup: ['dodge', 'jump'], // Safe escape attackActive: ['dodge'], // Emergency out attackRecovery: ['attack', 'dodge', 'jump'], // Combo options dodge: [], // Committed during dodge }
// The key is asymmetry: // - Some actions are committal (risk) // - Some actions are cancelable (safety) // - Resources gate escapes (stamina for roll)
// Cancel system implementation class ActionStateMachine { canCancelInto(fromAction: string, toAction: string): boolean { const allowedCancels = CANCEL_RULES[this.currentPhase] return allowedCancels.includes(toAction) }
update() { for (const action of INPUT_PRIORITY_ORDER) { if (input.justPressed(action) && this.canCancelInto(this.currentAction, action)) { this.startAction(action) return } } } }
// Priority order matters for conflicting inputs const INPUT_PRIORITY_ORDER = [ 'dodge', // Highest - survival 'block', 'special', 'attack', // Lowest ]
Symptoms
- Combat feels "mash-y" with no thought required
- No tension - player is never at risk
- All strategies reduce to "attack, dodge if needed"
- Skilled and unskilled players perform similarly
Detection Pattern
Hitstop Missing Or Wrong
Id
hitstop-missing-or-wrong
Summary
No freeze frame on hit, or asymmetric hitstop applied incorrectly
Severity
high
Situation
Attacks connect but don't feel impactful
Why
Hitstop is the single most important "game feel" technique for combat. When an attack connects, both attacker and target should freeze briefly. This gives the brain time to register the hit.
Without hitstop, hits feel like they "pass through" enemies. Combat feels floaty, unsatisfying, like hitting air.
Hitstop duration should scale with attack power:
- Light: 3-5 frames (50-83ms)
- Medium: 6-10 frames (100-167ms)
- Heavy: 12-18 frames (200-300ms)
Solution
class HitstopManager { frozen: boolean = false framesRemaining: number = 0
apply(frames: number) { // Take the larger hitstop if overlapping this.framesRemaining = Math.max(this.framesRemaining, frames) this.frozen = true }
update(): boolean { if (this.frozen && this.framesRemaining > 0) { this.framesRemaining--
if (this.framesRemaining <= 0) { this.frozen = false }
return true // Game is frozen } return false // Normal update } }
// Apply hitstop when hit connects function onHitConfirmed(attacker, target, attackData) { const hitstopFrames = calculateHitstop(attackData.damage)
// BOTH entities freeze - this is critical attacker.freezeFor(hitstopFrames) target.freezeFor(hitstopFrames)
// Global hitstop for camera/effects hitstopManager.apply(hitstopFrames)
// Spawn effects DURING hitstop // This is when the player registers the hit spawnHitEffect(target.position) playHitSound(attackData.type) screenShake.addTrauma(attackData.damage / 100) }
// Asymmetric hitstop (advanced) // Attacker freezes slightly less, feels like follow-through function applyAsymmetricHitstop(attacker, target, frames) { target.freezeFor(frames) // Full freeze attacker.freezeFor(frames * 0.7) // 70% freeze }
Symptoms
- Hits feel like they "pass through" enemies
- Combat feels "floaty" or "weightless"
- Attacks don't feel powerful regardless of damage
- No sense of impact or connection
Detection Pattern
onHit|hitConfirmed|applyDamage.*(?!freeze|hitstop|stop)
Stamina Too Restrictive
Id
stamina-too-restrictive
Summary
Stamina costs so high that combat becomes wait-and-watch
Severity
medium
Situation
Player can only swing 2-3 times before waiting 5+ seconds to recover
Why
Stamina should create strategic decisions, not forced downtime. If stamina runs out after 2-3 actions, players spend more time waiting than playing. This kills pacing and fun.
Good stamina design:
- 4-6 light attacks per bar
- 2-3 heavy attacks per bar
- 2-3 dodges per bar
- Regen begins quickly (~0.5 sec delay)
- Full regen in 2-4 seconds
Bad stamina design:
- 2 attacks per bar
- 1 dodge per bar
- 5+ second regen time
Solution
// Stamina budget sanity check const STAMINA_BUDGET = { maxStamina: 100,
costs: { lightAttack: 15, // 6-7 per bar heavyAttack: 30, // 3 per bar dodge: 20, // 5 per bar sprint: 5, // Per second block: 0 // Free but regen paused },
recovery: { rate: 30, // Per second = ~3.3 sec full regen delayAfterAction: 0.5, // Quick restart emptyPenalty: 1.5 // Extra delay when depleted } }
// Validate stamina design function validateStaminaDesign(config) { const warnings = []
// Check attack count per bar const lightAttacksPerBar = config.maxStamina / config.costs.lightAttack if (lightAttacksPerBar < 4) { warnings.push(Only ${lightAttacksPerBar} light attacks per bar - may feel restrictive) }
// Check regen time const fullRegenTime = config.maxStamina / config.recovery.rate if (fullRegenTime > 4) { warnings.push(${fullRegenTime}s to full regen - may cause excessive waiting) }
// Check dodge availability const dodgesPerBar = config.maxStamina / config.costs.dodge if (dodgesPerBar < 3) { warnings.push(Only ${dodgesPerBar} dodges per bar - may feel punishing) }
return warnings }
// Dynamic stamina based on combat state // More forgiving in easier fights, stricter in hard ones function adjustStaminaForDifficulty(baseConfig, difficulty) { return { ...baseConfig, recovery: { ...baseConfig.recovery, rate: baseConfig.recovery.rate (1.2 - difficulty 0.2) } } }
Symptoms
- Combat feels "slow" or "boring"
- Players stand around waiting for stamina
- Aggressive play feels impossible
- Stamina system feels like a punishment, not a strategy
Detection Pattern
Iframes Wrong Duration
Id
iframes-wrong-duration
Summary
I-frames too short (useless) or too long (trivializing)
Severity
high
Situation
Dodge i-frames don't protect reliably, or protect so long timing doesn't matter
Why
I-frames are a skill expression mechanic. They should:
- Reward timing (shorter = harder)
- Feel reliable (not random)
- Have consistent windows
Too short: Dodging feels random, unreliable Too long: Timing doesn't matter, just spam dodge
Reference values (at 60fps):
- Dark Souls roll: ~13 i-frames in 30-frame roll
- Bloodborne quickstep: ~10 i-frames in 26-frame dash
- DMC dodge: ~8 i-frames but very responsive
Solution
// I-frame tuning guide const IFRAME_GUIDELINES = { // Challenging (Souls-like) challenging: { iframes: 10, // 167ms window dodgeDuration: 26, // 433ms total ratio: 0.38 // 38% of dodge is invincible },
// Standard (most action games) standard: { iframes: 14, // 233ms window dodgeDuration: 24, // 400ms total ratio: 0.58 // 58% of dodge is invincible },
// Forgiving (casual/mobile) forgiving: { iframes: 18, // 300ms window dodgeDuration: 24, // 400ms total ratio: 0.75 // 75% of dodge is invincible } }
// I-frame implementation class DodgeController { state: 'ready' | 'startup' | 'iframes' | 'recovery' = 'ready' stateFrame: number = 0
config = { startup: 2, // Vulnerable (can be hit out of dodge) iframes: 12, // Invincible recovery: 10 // Vulnerable (punishable) }
update() { this.stateFrame++
switch (this.state) { case 'startup': if (this.stateFrame >= this.config.startup) { this.state = 'iframes' this.stateFrame = 0 } break
case 'iframes': if (this.stateFrame >= this.config.iframes) { this.state = 'recovery' this.stateFrame = 0 } break
case 'recovery': if (this.stateFrame >= this.config.recovery) { this.state = 'ready' } break } }
isInvincible(): boolean { return this.state === 'iframes' } }
// TESTING: Verify i-frames feel right // 1. Record first-time playtesters dodging attacks // 2. Success rate should be 60-80% when trying // 3. If < 50%: I-frames too short or startup too long // 4. If > 90%: I-frames too long, no skill required
Symptoms
- (Too short) Dodging feels random/unreliable
- (Too long) Combat trivial once dodge unlocked
- Players say "I pressed dodge!" when hit
- Dodging requires memorization, not reaction
Detection Pattern
Combat Design - Validations
Combat Input Without Buffering
Id
combat-no-input-buffer
Severity
warning
Type
regex
Pattern
(justPressed|isJustPressed|GetButtonDown)\s\([^)]\)\s(?!.buffer)
Message
Input check without buffering. Combat inputs should be buffered for responsiveness.
Fix Action
Add input buffering: store input with timestamp, consume when action becomes possible
Applies To
- *.ts
- *.js
- *.cs
- *.gd
- *.cpp
Frame-Critical Raw Input
Id
combat-raw-input-timing
Severity
warning
Type
regex
Pattern
if\s\(\s(Input|input)\.(justPressed|GetButtonDown|is_action_just_pressed)\s\([^)]\)\s\)\s\{
Message
Raw input for combat action. Consider input buffering for frame-perfect actions.
Fix Action
Buffer combat inputs: queue input, check buffer when action window opens
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Slow Animation Blend for Combat
Id
combat-slow-blend-time
Severity
warning
Type
regex
Pattern
(crossFade|CrossFade|blend_time|transitionDuration)\s[=:(\s]+\s0\.[2-9]
Message
Slow animation blend (>= 0.2s) in potential combat context. Attack animations should blend in < 50ms.
Fix Action
Use faster blend for combat: crossFade(anim, 0.033) for 2-frame transition
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Animation Without Damage Frame Event
Id
combat-no-animation-event
Severity
info
Type
regex
Pattern
(attackAnim|attack_anim|AttackAnimation)\s[=:]\s["'][^"']+"'
Message
Attack animation reference without visible event/notify setup. Damage timing should use animation events.
Fix Action
Add animation events for damage frames, sound triggers, and VFX spawning
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Hitbox Without Deactivation
Id
combat-hitbox-always-active
Severity
error
Type
regex
Pattern
(hitbox|Hitbox|hit_box)\.(enabled|active|SetActive)\s[=(\s]+\strue(?![^}]*false)
Message
Hitbox enabled without corresponding disable. Hitboxes should only be active during attack's active frames.
Fix Action
Disable hitbox after active frames: hitbox.enabled = false in recovery phase
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Hitbox Without Multi-Hit Prevention
Id
combat-no-hitbox-reset
Severity
warning
Type
regex
Pattern
class\s+Hitbox[^{]\{(?)
Message
Hitbox class without hit tracking. Same hitbox can hit same target multiple times per swing.
Fix Action
Track hit entities per attack: Set<Entity> hitThisSwing, check before applying damage
Applies To
- *.ts
- *.js
- *.cs
Damage Without Hitstop
Id
combat-damage-no-hitstop
Severity
warning
Type
regex
Pattern
(applyDamage|takeDamage|TakeDamage|deal_damage)\s\([^)]\)(?![^;{]*(?:hitstop|hitStop|freeze|Freeze|pause|Pause))
Message
Damage dealt without hitstop/freeze frame. Hits may feel weightless.
Fix Action
Add hitstop on hit: freeze both attacker and target for 3-15 frames based on damage
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Damage Without Screen Shake
Id
combat-damage-no-screenshake
Severity
info
Type
regex
Pattern
(applyDamage|takeDamage|TakeDamage|deal_damage)\s\([^)]\)(?![^;{]*(?:shake|Shake|trauma|Trauma|camera))
Message
Damage dealt without camera shake. Consider adding screen shake for impact feedback.
Fix Action
Add screen shake on hit: camera.addTrauma(damage / maxDamage)
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Combat Hit Without Visual Effect
Id
combat-no-hit-vfx
Severity
info
Type
regex
Pattern
(onHit|on_hit|OnHitConfirmed)\s[=:(\s]+(?)
Message
Hit handler without VFX spawn. Visual feedback reinforces impact.
Fix Action
Spawn hit particles at impact point scaled to damage
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Attack Without Recovery Definition
Id
combat-no-recovery-frames
Severity
warning
Type
regex
Pattern
attack(Data)?[=:\s]+\{[^}](startup|active)(?![^}]recovery)
Message
Attack data defines startup/active but not recovery frames. All attacks need recovery windows.
Fix Action
Define recovery frames: period after active frames where attacker is vulnerable
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Hardcoded Frame Data Values
Id
combat-magic-frame-numbers
Severity
info
Type
regex
Pattern
(startup|active|recovery)\s[=:]\s\d+(?!\s[/])
Message
Hardcoded frame data values. Consider using named constants or data-driven config.
Fix Action
Use named constants: LIGHT_ATTACK_STARTUP = 5, or load from config file
Applies To
- *.ts
- *.js
- *.cs
- *.gd
I-Frame Without Duration Limit
Id
combat-iframe-no-duration
Severity
error
Type
regex
Pattern
(invincible|isInvincible|invulnerable)\s=\strue(?![^}]*(timer|duration|frames|setTimeout|yield))
Message
Invincibility enabled without clear duration. I-frames should have explicit frame count.
Fix Action
Set i-frame duration: this.iframeFrames = 12; disable when counter reaches 0
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Dodge Without I-Frame Implementation
Id
combat-dodge-no-iframe
Severity
warning
Type
regex
Pattern
(startDodge|dodge|roll|Roll)\s\([^)]\)\s\{(?)
Message
Dodge function without i-frame implementation. Dodges typically grant invincibility frames.
Fix Action
Add i-frames to dodge: set invincible after startup, clear after i-frame window
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Combat Action Without Resource Cost
Id
combat-action-no-cost
Severity
info
Type
regex
Pattern
(attack|dodge|roll|heavyAttack)\s\([^)]\)\s\{(?)
Message
Combat action without resource cost. Consider stamina/energy costs for strategic depth.
Fix Action
Add resource costs: if (!stamina.canAfford(DODGE_COST)) return; stamina.spend(DODGE_COST)
Applies To
- *.ts
- *.js
- *.cs
Stamina With Immediate Regeneration
Id
combat-stamina-instant-regen
Severity
info
Type
regex
Pattern
stamina\s\+=(?!.delay|.timer|.after)
Message
Stamina regenerating without delay. Consider delay after action before regen starts.
Fix Action
Add regen delay: lastActionTime = now; only regen if (now - lastActionTime) > regenDelay
Applies To
- *.ts
- *.js
- *.cs
Enemy Attack Without Startup
Id
combat-enemy-instant-attack
Severity
warning
Type
regex
Pattern
enemy(Attack)?[=:\s]+\{[^}]damage(?![^}]startup|telegraph)
Message
Enemy attack data without startup/telegraph frames. Enemies need readable wind-ups.
Fix Action
Add startup frames >= 12 (200ms) for reactable attacks, >= 24 for comfortable reactions
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Boss Without Phase System
Id
combat-boss-no-phases
Severity
info
Type
regex
Pattern
class\s+Boss[^{]\{(?)
Message
Boss class without visible phase system. Long boss fights need phase transitions for variety.
Fix Action
Add phases: check HP thresholds, unlock new attacks, change behavior patterns
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Combo Without Input Window
Id
combat-combo-no-window
Severity
warning
Type
regex
Pattern
(comboNext|nextAttack|chainAttack)\s(?!.window|timer|frame)
Message
Combo system without input window tracking. Combos need defined input windows.
Fix Action
Add combo window: accept next input only during frames X-Y of current attack
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Cancel System Without Priority
Id
combat-cancel-no-hierarchy
Severity
info
Type
regex
Pattern
(canCancel|canInterrupt)\s[=:\s]+(?!.priority|hierarchy|level)
Message
Cancel system without priority hierarchy. Cancels should follow: Normal < Special < Super.
Fix Action
Implement cancel hierarchy: special cancels normal, super cancels special
Applies To
- *.ts
- *.js
- *.cs
Combat Timing Without Delta Time
Id
combat-frame-rate-dependent
Severity
error
Type
regex
Pattern
(frameCount|frame_count)\s[\+\-]=\s1(?!.delta|.Time)
Message
Frame counting without delta time consideration. Combat timing may vary with frame rate.
Fix Action
Use fixed timestep for combat logic, or multiply by (targetFrameRate / actualFrameRate)
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Combat Logic Skipping Frames
Id
combat-update-frame-skip
Severity
warning
Type
regex
Pattern
(attackFrame|combatTimer)\s\+=\s(delta|deltaTime|Delta)(?!.fixed|.step)
Message
Combat frame counter using raw delta time. Frame data may be inconsistent.
Fix Action
Use fixed timestep: accumulator += delta; while (accumulator >= FIXED_STEP) {...}
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Jump Without Coyote Time
Id
combat-no-coyote-time
Severity
warning
Type
regex
Pattern
(canJump|CanJump)\s[=:(\s]+\s(isGrounded|is_on_floor|IsGrounded)(?!.coyote|.grace)
Message
Jump check using only ground state. Add coyote time for forgiving platform combat.
Fix Action
Track lastGroundedFrame; allow jump if (currentFrame - lastGroundedFrame) <= coyoteFrames
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Jump Without Input Buffering
Id
combat-no-jump-buffer
Severity
info
Type
regex
Pattern
if\s\([^)]jump[^)]\)\s\{[^}]\}(?![^}]buffer)
Message
Jump input without buffering. Buffer jump presses before landing for responsiveness.
Fix Action
Buffer jump input; on landing check if (jumpBuffer.hasBufferedInput()) jump()
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Expensive Collision Every Frame
Id
combat-collision-every-frame
Severity
warning
Type
regex
Pattern
(update|Update|_process)\s\([^)]\)\s\{[^}](checkCollision|CheckCollision|overlap|Overlap)
Message
Collision checking in update loop. May cause performance issues; only check when hitbox active.
Fix Action
Only check collisions when hitbox is active, use collision layers, spatial partitioning
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Hitbox Without Once-Per-Swing Check
Id
combat-no-hit-once-check
Severity
warning
Type
regex
Pattern
(onTriggerEnter|OnTriggerEnter|body_entered)\s\([^)]\)\s\{(?)
Message
Collision handler without hit-once check. Same hitbox may deal damage multiple times.
Fix Action
Track hit entities: if (hitThisSwing.has(entity)) return; hitThisSwing.add(entity)
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Combat State Boolean Soup
Id
combat-state-boolean-soup
Severity
warning
Type
regex
Pattern
is(Attacking|Dodging|Blocking)[^&|]&&[^&|]is(Attacking|Dodging|Blocking)
Message
Multiple boolean state checks. Use a state machine for combat states.
Fix Action
Replace booleans with state enum: CombatState.ATTACKING, use switch statement
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Combat State Without Exit Handler
Id
combat-no-exit-state
Severity
info
Type
regex
Pattern
(enterState|onEnter|Enter)\s\([^)]\)\s\{(?)
Message
State machine with enter but no exit handler. Clean up state on transitions.
Fix Action
Add onExit handler: disable hitboxes, reset timers, restore movement
Applies To
- *.ts
- *.js
- *.cs
- *.gd
Lock-On Without Target Validation
Id
combat-lockon-no-validation
Severity
warning
Type
regex
Pattern
(lockOn|LockOn|lockedTarget)\s=\s[^;]+(?!.*valid|alive|exists|null)
Message
Lock-on target set without validation. Target may be dead or out of range.
Fix Action
Validate lock-on target each frame: if (!target.isAlive || distance > maxRange) unlock()
Applies To
- *.ts
- *.js
- *.cs
- *.gd