Genre: Fighting Game
Expert blueprint for 2D/3D fighters emphasizing frame-perfect combat and competitive balance.
NEVER Do
- NEVER use variable frame rates — Fighting games require fixed 60fps. Implement custom frame-based loop, not
_physics_process(delta). - NEVER skip input buffering — Without 5-10 frame buffer, players miss inputs. Command inputs feel unresponsive.
- NEVER forget damage scaling — Infinite combos break competitive play. Apply 10% damage reduction per hit in combo.
- NEVER make all moves safe on block — If all attacks have +0 or better advantage on block, defense has no value. Mix safe and unsafe moves.
- NEVER use client-side hit detection for netplay — Client predicts, server validates. Peer-to-peer needs rollback netcode or desyncs occur.
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
fighting_input_buffer.gd
Frame-locked input polling with motion command detection. Stores 20-frame history, fuzzy-matches QCF/DP inputs, uses _physics_process for deterministic timing.
Core Loop
Neutral Game → Confirm Hit → Execute Combo → Advantage State → Repeat
Skill Chain
godot-project-foundations, godot-characterbody-2d, godot-input-handling, animation, godot-combat-system, godot-state-machine-advanced, multiplayer-lobby
Frame-Based Combat System
Fighting games operate on frame data - discrete time units (typically 60fps).
Frame Data Fundamentals
class_name Attack
extends Resource
@export var name: String
@export var startup_frames: int # Frames before hitbox becomes active
@export var active_frames: int # Frames hitbox is active
@export var recovery_frames: int # Frames after hitbox deactivates
@export var on_hit_advantage: int # Frame advantage when attack hits
@export var on_block_advantage: int # Frame advantage when blocked
@export var damage: int
@export var hitstun: int # Frames opponent is stunned
@export var blockstun: int # Frames opponent is in blockstun
func get_total_frames() -> int:
return startup_frames + active_frames + recovery_frames
func is_safe_on_block() -> bool:
return on_block_advantage >= 0
Frame-Accurate Processing
extends Node
var frame_count: int = 0
const FRAME_DURATION := 1.0 / 60.0
var accumulator: float = 0.0
func _process(delta: float) -> void:
accumulator += delta
while accumulator >= FRAME_DURATION:
process_game_frame()
frame_count += 1
accumulator -= FRAME_DURATION
func process_game_frame() -> void:
# All game logic runs here at fixed 60fps
for fighter in fighters:
fighter.process_frame()
Input System
Input Buffering
Store inputs and execute when valid:
class_name InputBuffer
extends Node
const BUFFER_FRAMES := 8 # Industry standard: 5-10 frames
var buffer: Array[InputEvent] = []
func add_input(input: InputEvent) -> void:
buffer.append(input)
if buffer.size() > BUFFER_FRAMES:
buffer.pop_front()
func consume_input(action: StringName) -> bool:
for i in range(buffer.size() - 1, -1, -1):
if buffer[i].is_action(action):
buffer.remove_at(i)
return true
return false
Motion Input Detection (Quarter Circle, DP, etc.)
class_name MotionDetector
extends Node
const QCF := ["down", "down_forward", "forward"] # Quarter Circle Forward
const DP := ["forward", "down", "down_forward"] # Dragon Punch
const MOTION_WINDOW := 15 # Frames to complete motion
var direction_history: Array[String] = []
func add_direction(dir: String) -> void:
if direction_history.is_empty() or direction_history[-1] != dir:
direction_history.append(dir)
# Keep last N directions
if direction_history.size() > 20:
direction_history.pop_front()
func check_motion(motion: Array[String]) -> bool:
if direction_history.size() < motion.size():
return false
# Check if motion appears in recent history
var recent := direction_history.slice(-MOTION_WINDOW)
return _contains_sequence(recent, motion)
func _contains_sequence(haystack: Array, needle: Array) -> bool:
var idx := 0
for dir in haystack:
if dir == needle[idx]:
idx += 1
if idx >= needle.size():
return true
return false
Hitbox/Hurtbox System
class_name HitboxComponent
extends Area2D
enum BoxType { HITBOX, HURTBOX, THROW, PROJECTILE }
@export var box_type: BoxType
@export var attack_data: Attack
@export var owner_fighter: Fighter
signal hit_confirmed(target: Fighter, attack: Attack)
func _ready() -> void:
monitoring = (box_type == BoxType.HITBOX or box_type == BoxType.THROW)
monitorable = (box_type == BoxType.HURTBOX)
connect("area_entered", _on_area_entered)
func _on_area_entered(area: Area2D) -> void:
if area is HitboxComponent:
var other := area as HitboxComponent
if other.box_type == BoxType.HURTBOX and other.owner_fighter != owner_fighter:
hit_confirmed.emit(other.owner_fighter, attack_data)
Combo System
Hit Confirmation and Combo Counter
class_name ComboTracker
extends Node
var combo_count: int = 0
var combo_damage: int = 0
var in_combo: bool = false
var damage_scaling: float = 1.0
const SCALING_PER_HIT := 0.9 # 10% reduction per hit
func start_combo() -> void:
in_combo = true
combo_count = 0
combo_damage = 0
damage_scaling = 1.0
func add_hit(base_damage: int) -> int:
combo_count += 1
var scaled_damage := int(base_damage * damage_scaling)
combo_damage += scaled_damage
damage_scaling *= SCALING_PER_HIT
return scaled_damage
func drop_combo() -> void:
in_combo = false
combo_count = 0
damage_scaling = 1.0
Cancel System
enum CancelType { NONE, NORMAL, SPECIAL, SUPER }
func can_cancel_into(from_attack: Attack, to_attack: Attack) -> bool:
# Normal → Special → Super hierarchy
match to_attack.cancel_type:
CancelType.NORMAL:
return from_attack.cancel_type == CancelType.NONE
CancelType.SPECIAL:
return from_attack.cancel_type in [CancelType.NONE, CancelType.NORMAL]
CancelType.SUPER:
return true # Supers can cancel anything
return false
Character States
enum FighterState {
IDLE, WALKING, CROUCHING, JUMPING,
ATTACKING, BLOCKING, HITSTUN, BLOCKSTUN,
KNOCKDOWN, WAKEUP, THROW, THROWN
}
class_name FighterStateMachine
extends Node
var current_state: FighterState = FighterState.IDLE
var state_frame: int = 0
func transition_to(new_state: FighterState) -> void:
exit_state(current_state)
current_state = new_state
state_frame = 0
enter_state(new_state)
func is_actionable() -> bool:
return current_state in [
FighterState.IDLE,
FighterState.WALKING,
FighterState.CROUCHING
]
Netcode Considerations
Rollback Essentials
class_name GameState
extends Resource
# Serialize complete game state for rollback
func save_state() -> Dictionary:
return {
"frame": frame_count,
"fighters": fighters.map(func(f): return f.serialize()),
"projectiles": projectiles.map(func(p): return p.serialize())
}
func load_state(state: Dictionary) -> void:
frame_count = state["frame"]
for i in fighters.size():
fighters[i].deserialize(state["fighters"][i])
# Reconstruct projectiles...
Balance Guidelines
| Element | Guideline |
|---|---|
| Health | 10,000-15,000 for ~20 second rounds |
| Combo damage | Max 30-40% of health per touch |
| Fastest moves | 3-5 frames startup (jabs) |
| Slowest moves | 20-40 frames (supers, overheads) |
| Throw range | Short but reliable |
| Meter gain | Full bar in ~2 combos received |
Common Pitfalls
| Pitfall | Solution |
|---|---|
| Infinite combos | Implement hitstun decay and gravity scaling |
| Unblockable setups | Ensure all attacks have counterplay |
| Lag input drops | Robust input buffering (8+ frames) |
| Desync in netplay | Deterministic physics, rollback netcode |
Godot-Specific Tips
- Use
_physics_processsparingly - implement your own frame-based loop - AnimationPlayer: Tie hitbox activation to animation frames
- Custom collision: May need custom hitbox system rather than physics engine
- Save/Load for rollback: Keep state serializable
Reference
- Master Skill: godot-master