Game code rots faster than any other code you will write. Without discipline, _process becomes a 400-line cascade of if flags: if is_moving and not is_stunned and can_act and not is_menu_open. State machines are the cure, and they're so useful that you should put one in place before you write a second condition.
Every game entity with complex behavior has a current state. A card is in_deck, in_hand, being_dragged, in_play_zone, discarded. A duel is player_turn, resolving, enemy_turn, victory, defeat. The entity does completely different things in each state.
Make states explicit. Here's the minimal version:
extends Node
class_name DuelController
enum State { PLAYER_DRAW, PLAYER_PLAY, RESOLVING, ENEMY_TURN, VICTORY, DEFEAT }
var state: State = State.PLAYER_DRAW
func _ready() -> void:
_enter_state(state)
func transition_to(new_state: State) -> void:
if new_state == state:
return
_exit_state(state)
state = new_state
_enter_state(state)
func _enter_state(s: State) -> void:
match s:
State.PLAYER_DRAW:
player.draw_to(7)
transition_to(State.PLAYER_PLAY)
State.PLAYER_PLAY:
ui.show_end_turn_button()
State.RESOLVING:
ui.hide_end_turn_button()
_resolve_played_word()
State.ENEMY_TURN:
await enemy.take_turn()
transition_to(State.PLAYER_DRAW)
State.VICTORY:
ui.show_victory_screen()
State.DEFEAT:
ui.show_defeat_screen()
func _exit_state(_s: State) -> void:
pass # cleanup hooks go here if needed
Three functions: transition_to, _enter_state, _exit_state. Every change goes through transition_to. Every state's entry logic lives in one place. It's almost embarrassingly simple. It'll carry you through a 30-hour game.
switch(action.type), you can read this. The reason it works in game dev is that games spend 90% of their time in a state and 10% transitioning โ so optimizing transitions matters.You'll see tutorials use strings for state names. Don't. Enums are type-checked (typos become compile errors), autocomplete works, renaming is safe.
# BAD
state = "player_turn"
if state == "Player_Turn": ... # typo, silently broken
# GOOD
state = State.PLAYER_TURN
if state == State.PLAYER_TURN: ... # compile error on typo
Above a certain complexity you'll want sub-states. Example: during PLAYER_PLAY, a card can be idle, being_dragged, or in_target_slot. You don't want to write one flat enum with every combination.
The simplest hierarchy is nested state machines. The Duel has a big state machine. Each Card has its own small one. They don't know about each other. The Duel state machine only cares that some card has been played (signal), not which card's internal state it's in.
# Card's internal state machine
extends Control
class_name Card
enum CardState { IN_HAND, DRAGGING, IN_SLOT, PLAYED, DISCARDED }
var card_state: CardState = CardState.IN_HAND
signal entered_slot(card: Card)
func transition_to(s: CardState) -> void:
if s == card_state: return
card_state = s
match s:
CardState.DRAGGING:
modulate.a = 0.7
z_index = 100
CardState.IN_SLOT:
modulate.a = 1.0
z_index = 0
entered_slot.emit(self)
CardState.PLAYED:
_animate_to_play_zone()
CardState.DISCARDED:
queue_free()
Each layer of the hierarchy owns its own state. The outer state machine listens for signals from the inner ones.
For complex entities (boss enemies, protagonists with dozens of abilities) even a single enum gets unwieldy. The next level up is making each state its own node, with the entity's base script managing which child-state is active.
Player (CharacterBody2D)
โโโ States (Node)
โ โโโ Idle (Node, script idle_state.gd)
โ โโโ Running (Node, script running_state.gd)
โ โโโ Attacking (Node, script attacking_state.gd)
โ โโโ Stunned (Node, script stunned_state.gd)
# base state class
class_name State
extends Node
var owner_entity: Node
func enter() -> void: pass
func exit() -> void: pass
func process(_delta: float) -> void: pass
func handle_input(_event: InputEvent) -> void: pass
# state machine manager
class_name StateMachine
extends Node
@export var initial: State
var current: State
func _ready() -> void:
for child in get_children():
if child is State:
child.owner_entity = get_parent()
current = initial
current.enter()
func transition_to(new_state: State) -> void:
if new_state == current: return
current.exit()
current = new_state
current.enter()
func _process(delta: float) -> void:
if current: current.process(delta)
For Lexicon Duel's scope, you probably don't need this level of ceremony. Start with the enum-based state machine. Refactor to State-as-Node only if the enum cases grow beyond ~8 or one state needs its own inner state.
Internal logic rarely calls transition_to directly. More often, a signal from somewhere else triggers the transition:
func _ready() -> void:
player_hand.word_submitted.connect(_on_word_submitted)
end_turn_button.pressed.connect(_on_end_turn_pressed)
func _on_word_submitted(word: String) -> void:
if state == State.PLAYER_PLAY:
transition_to(State.RESOLVING)
func _on_end_turn_pressed() -> void:
if state == State.PLAYER_PLAY:
transition_to(State.ENEMY_TURN)
Notice the guard: if state == State.PLAYER_PLAY. The button can only fire when we're in a state that cares. Every signal handler should check the state it's valid in. This is how you keep the state machine authoritative instead of advisory.
Print every transition:
func transition_to(new_state: State) -> void:
if new_state == state: return
print("[DuelController] %s -> %s" % [State.keys()[state], State.keys()[new_state]])
_exit_state(state)
state = new_state
_enter_state(new_state)
State.keys()[state] converts the enum int back to its name. Cheap, readable logs. Leave these in; you'll thank yourself when a weird bug happens at 11pm.
scripts/duel_controller.gd with the first code block (DuelController with 6 states).# TODO inside _enter_state._ready, trigger transition_to(State.PLAYER_PLAY) manually and watch the log print transitions.