A comprehensive guide to the state management system powering Console Jack's game flow.
- Overview
- Core Concepts
- LoopableState Interface
- StateMachineManager API
- State Lifecycle
- Creating States
- State Transitions
- Best Practices
- Real-World Examples
- Common Patterns
- Troubleshooting
The State Machine is the foundational architecture pattern for Console Jack. Every screen, menu, and game mode is implemented as a state that manages its own logic, input handling, rendering, and lifecycle.
- Organized Game Flow: Each screen is encapsulated in its own state
- Stack-Based Navigation: Push/pop states for menus and overlays
- Clean Transitions: Automatic pause/resume when overlaying states
- Resource Management: Clear lifecycle hooks for setup and cleanup
- Thread-Safe: Lock-protected state transitions
- Simple API: Facade pattern via
StateMachineManager
MasterSubsystem (Game Loop)
↓
StateMachineCoordinator (LIFO Stack)
↓
StateMachineManager (Public Facade)
↓
Game States (MainMenuState, GameplayState, etc.)
The state machine uses a Last-In-First-Out (LIFO) stack to manage states:
┌─────────────────┐
│ PauseState │ ← Top (Active)
├─────────────────┤
│ GameplayState │ ← Paused
├─────────────────┤
│ MainMenuState │ ← Paused
└─────────────────┘
- Active State: Only the top state receives
update(),render(), andhandleInput()calls - Paused States: Lower states remain on the stack but are inactive
- Push: Adds a new state on top (pauses the previous active state)
- Pop: Removes the top state (resumes the next state)
- Replace: Replaces the active state with a new one
Every state goes through these phases:
- Creation: State object instantiated
- Start:
start()called → Initialize resources - Active: Receives
update(),render(),handleInput()calls - Pause:
pause()called → Another state pushed on top - Resume:
resume()called → Overlaying state popped - End:
end()called → Cleanup resources - Destruction: Object eligible for garbage collection
All game states implement the LoopableState interface.
package net.luxsolari.engine.states;
public interface LoopableState {
void start(); // Initialize state (called once)
void pause(); // State paused by overlay
void resume(); // State resumed after overlay
void handleInput(); // Process user input
void update(); // Update game logic
void render(); // Render visuals
void end(); // Cleanup resources (called once)
// Helper method available to all states
default boolean renderReady() {
return RenderSubsystem.INSTANCE.ready();
}
}Called once when the state becomes active for the first time.
Responsibilities:
- Initialize UI components (menus, labels)
- Set input context via
InputManager.setContext() - Start background music via
AudioManager.playBGM() - Create entities in
EntityPool - Load resources
- Set up initial state variables
Example:
@Override
public void start() {
LOGGER.info("Gameplay started");
// Set input context
InputManager.setContext(new GameplayInputContext());
// Start music
AudioManager.playBGM("gameplay_theme", true);
// Initialize UI
instructionLabels = createInstructionLabels();
// Create game entities
createDeck();
}Called when another state is pushed on top of this one.
Responsibilities:
- Stop background music (optional)
- Pause animations
- Save transient state if needed
- DO NOT destroy resources (state will resume)
Example:
@Override
public void pause() {
LOGGER.info("Gameplay paused");
// Optional: stop music to avoid overlap
// AudioManager.stopBGM();
}Called when an overlaying state is popped and this state becomes active again.
Responsibilities:
- Re-set input context (important!)
- Resume background music
- Clear and redraw render layers
- Reset focus on UI components
- Resume animations
Example:
@Override
public void resume() {
LOGGER.info("Gameplay resumed");
// Re-set input context (critical!)
InputManager.setContext(new GameplayInputContext());
// Resume music
AudioManager.playBGM("gameplay_theme", true);
// Clear and redraw
RenderManager.clearAll();
if (menu != null) {
menu.resetFocus();
menu.focus();
}
}Called every frame by the master game loop.
Responsibilities:
- Check if render system is ready
- Poll for input via
InputManager.pollCommand() - Handle input commands
- Delegate to UI components if needed
Example:
@Override
public void handleInput() {
if (!renderReady()) {
return;
}
InputResult input = InputManager.pollCommand();
if (input == null || input.command() == null) {
return;
}
switch (input.command()) {
case QUIT -> MasterSubsystem.INSTANCE.stop();
case PAUSE -> StateMachineManager.push(new PauseState());
case HIT -> handleHit();
case STAND -> handleStand();
}
}Called every frame (8 UPS) for game logic updates.
Responsibilities:
- Update game state (scores, timers, etc.)
- Process AI logic
- Update animations
- Check win/loss conditions
Example:
@Override
public void update() {
// Update timers
if (turnTimer > 0) {
turnTimer--;
}
// Check game over
if (isGameOver()) {
showGameOverScreen();
}
}Called every frame for visual updates.
Responsibilities:
- Clear render layers
- Render UI components
- Render game entities
- DO NOT modify game state here
Example:
@Override
public void render() {
clearUILayers();
if (!renderReady()) {
return;
}
// Render UI
if (menu != null) {
menu.render(RenderManager.UI_LAYER);
}
// Render labels
renderInstructionLabels();
}Called once when the state is being removed from the stack.
Responsibilities:
- Stop background music
- Unfocus UI components
- Clear entity pool entries
- Null out references for garbage collection
- Release any held resources
Example:
@Override
public void end() {
LOGGER.info("Gameplay ended");
// Stop music
AudioManager.stopBGM();
// Cleanup UI
if (menu != null) {
menu.unfocus();
menu = null;
}
// Cleanup labels
if (instructionLabels != null) {
instructionLabels.clear();
instructionLabels = null;
}
// Clear entities
entityPool.removeWith(Card.class);
}The StateMachineManager is a stateless facade providing access to the state machine.
Pushes a new state onto the stack, making it active.
Behavior:
- Current active state (if any) receives
pause() - New state is added to the top of the stack
- All render layers are cleared
- New state receives
start()
Use Cases:
- Opening overlay menus (pause menu, dialogs)
- Transitioning to a new screen while keeping the previous one in memory
Example:
// Open pause menu (overlay)
StateMachineManager.push(new PauseState());
// Open dialog (overlay)
StateMachineManager.push(new DialogState("Are you sure?"));Removes the active state from the stack.
Behavior:
- Active state receives
end() - State is removed from the stack
- All render layers are cleared
- Next state (if any) receives
resume()
Use Cases:
- Closing overlay menus
- Returning to the previous screen
Example:
// Close pause menu
StateMachineManager.pop();
// Return to previous screen
StateMachineManager.pop();Replaces the active state with a new one. Equivalent to pop() + push().
Behavior:
- Current active state receives
end() - State is removed from the stack
- New state is added to the stack
- All render layers are cleared
- New state receives
start()
Use Cases:
- Transitioning between main screens (main menu → gameplay)
- State transitions where you don't want to keep the previous state
Example:
// Start game from main menu
StateMachineManager.replace(new GameplayState());
// Return to main menu from game over
StateMachineManager.replace(new MainMenuState());Removes all states from the stack.
Behavior:
- All states receive
end()in LIFO order - Stack is emptied
- All render layers are cleared
Use Cases:
- Resetting to a clean state
- Quitting to main menu from deep in the game
Example:
// Quit to main menu from anywhere
StateMachineManager.clear();
StateMachineManager.push(new MainMenuState());Returns the currently active state.
Returns: LoopableState or null if stack is empty
Example:
LoopableState current = StateMachineManager.active();
if (current instanceof GameplayState) {
// Do something gameplay-specific
}Checks if any states exist in the stack.
Returns: true if at least one state exists
Example:
if (!StateMachineManager.hasStates()) {
// Stack is empty, push initial state
StateMachineManager.push(new MainMenuState());
}┌─────────────────────────────────────────────────────────────┐
│ 1. State Created (new GameplayState()) │
└───────────────────────────┬─────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 2. start() called │
│ - Initialize resources │
│ - Set input context │
│ - Start music │
└───────────────────────────┬─────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 3. Active Loop (every frame) │
│ - handleInput() │
│ - update() │
│ - render() │
└───────────────────┬───────────────┬─────────────────────────┘
↓ ↓
┌───────────────────┐ ┌──────────────────┐
│ Another state │ │ State popped │
│ pushed on top │ │ │
└─────────┬─────────┘ └────────┬─────────┘
↓ ↓
┌───────────────────┐ ┌──────────────────┐
│ 4a. pause() │ │ 6. end() │
│ called │ │ - Cleanup │
└─────────┬─────────┘ │ - Stop music │
↓ │ - Null refs │
┌───────────────────┐ └────────┬─────────┘
│ State Paused │ ↓
│ (on stack but │ ┌──────────────────┐
│ not active) │ │ 7. State │
└─────────┬─────────┘ │ Destroyed │
↓ └──────────────────┘
┌───────────────────┐
│ Overlay state │
│ popped │
└─────────┬─────────┘
↓
┌───────────────────┐
│ 5. resume() │
│ called │
│ - Reset │
│ context │
│ - Redraw │
└─────────┬─────────┘
↓
Back to Active Loop (step 3)
push(StateB) pop()
StateA ──────────→ StateB ──────────→ StateA
pause() resume()
replace(StateC)
StateA ─────────────→ StateC
end() start()
package net.luxsolari.game.states;
import net.luxsolari.engine.states.LoopableState;
import net.luxsolari.engine.manager.*;
import net.luxsolari.engine.input.*;
import java.util.logging.Logger;
public class MyGameState implements LoopableState {
private static final String TAG = MyGameState.class.getSimpleName();
private static final Logger LOGGER = Logger.getLogger(TAG);
@Override
public void start() {
LOGGER.info("MyGameState started");
// Set input context
InputManager.setContext(new MyGameInputContext());
// Initialize resources
}
@Override
public void pause() {
LOGGER.info("MyGameState paused");
}
@Override
public void resume() {
LOGGER.info("MyGameState resumed");
InputManager.setContext(new MyGameInputContext());
RenderManager.clearAll();
}
@Override
public void handleInput() {
if (!renderReady()) {
return;
}
InputResult input = InputManager.pollCommand();
if (input == null || input.command() == null) {
return;
}
// Handle input
}
@Override
public void update() {
// Update logic
}
@Override
public void render() {
if (!renderReady()) {
return;
}
// Render visuals
}
@Override
public void end() {
LOGGER.info("MyGameState ended");
// Cleanup
}
}public class MenuState implements LoopableState {
private static final String TAG = MenuState.class.getSimpleName();
private static final Logger LOGGER = Logger.getLogger(TAG);
private Menu menu;
@Override
public void start() {
LOGGER.info("MenuState started");
InputManager.setContext(new MenuInputContext());
AudioManager.playBGM("menu_theme", true);
menu = new Menu("My Menu")
.addItem("Option 1", this::option1)
.addItem("Option 2", this::option2)
.addItem("Back", () -> StateMachineManager.pop())
.setBorder(true);
menu.focus();
}
@Override
public void pause() {
LOGGER.info("MenuState paused");
AudioManager.stopBGM();
}
@Override
public void resume() {
LOGGER.info("MenuState resumed");
InputManager.setContext(new MenuInputContext());
AudioManager.playBGM("menu_theme", true);
if (menu != null) {
RenderManager.clearAll();
menu.resetFocus();
menu.focus();
}
}
@Override
public void handleInput() {
if (!renderReady() || menu == null) {
return;
}
InputResult input = InputManager.pollCommand();
if (input == null || input.command() == null) {
return;
}
// Handle state-level commands
if (input.command() == InputCommand.QUIT) {
MasterSubsystem.INSTANCE.stop();
return;
}
// Delegate to menu
menu.handleCommand(input.command());
}
@Override
public void update() {}
@Override
public void render() {
clearUILayers();
if (!renderReady() || menu == null) {
return;
}
menu.render(RenderManager.UI_LAYER);
}
@Override
public void end() {
LOGGER.info("MenuState ended");
AudioManager.stopBGM();
if (menu != null) {
menu.unfocus();
menu = null;
}
}
private void clearUILayers() {
for (int layer = RenderManager.UI_LAYER;
layer < RenderManager.getLayerCount(); layer++) {
RenderManager.clear(layer);
}
}
private void option1() { /* implementation */ }
private void option2() { /* implementation */ }
}// In MainMenuState
menu.addItem("Start Game", () -> {
StateMachineManager.replace(new GameplayState());
});Why replace(): We don't need to keep the main menu in memory during gameplay.
// In GameplayState handleInput()
case PAUSE -> StateMachineManager.push(new PauseState());Why push(): We want to overlay the pause menu while keeping gameplay state intact.
// In PauseState
menu.addItem("Resume", () -> {
StateMachineManager.pop();
});Why pop(): Simply remove the pause menu to return to gameplay.
// In PauseState
menu.addItem("Quit to Main Menu", () -> {
StateMachineManager.clear();
StateMachineManager.push(new MainMenuState());
});Why clear() + push(): Remove all states (pause + gameplay) and start fresh with main menu.
private void showDialog() {
StateMachineManager.push(new LoopableState() {
private Menu dialogMenu;
@Override
public void start() {
dialogMenu = new Menu("Dialog")
.addItem("OK", () -> StateMachineManager.pop())
.setBorder(true);
dialogMenu.focus();
}
@Override
public void handleInput() {
if (dialogMenu != null) {
KeyStroke ks = InputManager.poll();
if (ks != null) {
dialogMenu.handleInput(ks);
}
}
}
@Override
public void render() {
if (dialogMenu != null) {
RenderManager.clear(RenderManager.UI_LAYER + 1);
dialogMenu.render(RenderManager.UI_LAYER + 1);
}
}
@Override
public void end() {
if (dialogMenu != null) {
dialogMenu.unfocus();
dialogMenu = null;
}
}
@Override public void pause() {}
@Override public void resume() {}
@Override public void update() {}
});
}@Override
public void resume() {
InputManager.setContext(new GameplayInputContext()); // ✅ Critical!
}Why: When states are pushed/popped, the input context can change. Always restore your context.
@Override
public void render() {
clearUILayers(); // ✅ Clear before rendering
menu.render(RenderManager.UI_LAYER);
}Why: Prevents visual artifacts from previous frames.
@Override
public void end() {
AudioManager.stopBGM(); // ✅ Stop audio
if (menu != null) {
menu.unfocus(); // ✅ Unfocus UI
menu = null; // ✅ Null reference
}
if (labels != null) {
labels.clear(); // ✅ Clear collections
labels = null;
}
}Why: Proper cleanup prevents memory leaks and resource contention.
@Override
public void handleInput() {
if (!renderReady()) { // ✅ Check render system
return;
}
// ... poll input
}Why: Multi-threaded startup requires checking if render subsystem is initialized.
@Override
public void start() {
LOGGER.info("GameplayState started"); // ✅ Log state changes
// ...
}Why: Makes debugging state transitions much easier.
@Override
public void render() {
// ✅ Good: Just rendering
menu.render(RenderManager.UI_LAYER);
// ❌ Bad: Modifying state
// score++;
// entities.remove(0);
}Why: Violates separation of concerns and can cause timing issues.
@Override
public void render() {
if (menu == null) { // ✅ Null check
return;
}
menu.render(RenderManager.UI_LAYER);
}Why: Prevents NPEs during state transitions.
// Main state
menu.render(RenderManager.UI_LAYER);
// Dialog/overlay state
dialogMenu.render(RenderManager.UI_LAYER + 2);Why: Ensures overlays render on top of the base state.
File: src/main/java/net/luxsolari/game/states/MainMenuState.java
public class MainMenuState implements LoopableState {
private static final String TAG = MainMenuState.class.getSimpleName();
private static final Logger LOGGER = Logger.getLogger(TAG);
private Menu mainMenu;
@Override
public void start() {
LOGGER.info("Main menu started");
AudioManager.playBGM("menu_theme", true);
InputManager.setContext(new MainMenuInputContext());
mainMenu = new Menu("Console Jack")
.addItem("Start Game", () -> {
StateMachineManager.replace(new GameplayState());
})
.addItem("Options", this::showOptions)
.addItem("Quit", MasterSubsystem.INSTANCE::stop)
.setBorder(true);
mainMenu.focus();
}
@Override
public void pause() {
LOGGER.info("Main menu paused");
AudioManager.stopBGM();
}
@Override
public void resume() {
LOGGER.info("Main menu resumed");
AudioManager.playBGM("menu_theme", true);
InputManager.setContext(new MainMenuInputContext());
if (mainMenu != null) {
RenderManager.clearAll();
mainMenu.resetFocus();
mainMenu.focus();
}
}
@Override
public void handleInput() {
if (!renderReady() || mainMenu == null) {
return;
}
InputResult input = InputManager.pollCommand();
if (input == null || input.command() == null) {
return;
}
if (input.command() == InputCommand.QUIT) {
MasterSubsystem.INSTANCE.stop();
return;
}
mainMenu.handleCommand(input.command());
}
@Override
public void update() {}
@Override
public void render() {
clearUILayers();
if (!renderReady() || mainMenu == null) {
return;
}
mainMenu.render(RenderManager.UI_LAYER);
}
@Override
public void end() {
LOGGER.info("Main menu ended");
AudioManager.stopBGM();
if (mainMenu != null) {
mainMenu.unfocus();
mainMenu = null;
}
}
private void clearUILayers() {
for (int layer = RenderManager.UI_LAYER;
layer < RenderManager.getLayerCount(); layer++) {
RenderManager.clear(layer);
}
}
private void showOptions() {
// Implementation omitted for brevity
}
}File: src/main/java/net/luxsolari/game/states/PauseState.java
public class PauseState implements LoopableState {
private static final String TAG = PauseState.class.getSimpleName();
private static final Logger LOGGER = Logger.getLogger(TAG);
private Menu pauseMenu;
@Override
public void start() {
LOGGER.info("Pause menu opened");
InputManager.setContext(new PauseInputContext());
pauseMenu = new Menu("Paused")
.addItem("Resume", () -> StateMachineManager.pop())
.addItem("Quit to Main Menu", () -> {
StateMachineManager.clear();
StateMachineManager.push(new MainMenuState());
})
.setBorder(true);
pauseMenu.focus();
}
@Override
public void pause() {
LOGGER.info("Pause menu paused");
}
@Override
public void resume() {
LOGGER.info("Pause menu resumed");
InputManager.setContext(new PauseInputContext());
if (pauseMenu != null) {
RenderManager.clearAll();
pauseMenu.resetFocus();
pauseMenu.focus();
}
}
@Override
public void handleInput() {
if (!renderReady() || pauseMenu == null) {
return;
}
InputResult input = InputManager.pollCommand();
if (input == null || input.command() == null) {
return;
}
switch (input.command()) {
case QUIT -> MasterSubsystem.INSTANCE.stop();
case RESUME -> StateMachineManager.pop();
case BACK -> {
StateMachineManager.clear();
StateMachineManager.push(new MainMenuState());
}
}
pauseMenu.handleCommand(input.command());
}
@Override
public void update() {}
@Override
public void render() {
clearUILayers();
if (!renderReady() || pauseMenu == null) {
return;
}
pauseMenu.render(RenderManager.UI_LAYER);
}
@Override
public void end() {
LOGGER.info("Pause menu closed");
if (pauseMenu != null) {
pauseMenu.unfocus();
pauseMenu = null;
}
}
private void clearUILayers() {
for (int layer = RenderManager.UI_LAYER;
layer < RenderManager.getLayerCount(); layer++) {
RenderManager.clear(layer);
}
}
}// Main menu → Gameplay
StateMachineManager.replace(new GameplayState());// Open pause menu (keeps gameplay in memory)
StateMachineManager.push(new PauseState());
// Close pause menu
StateMachineManager.pop();// From anywhere in the game
StateMachineManager.clear();
StateMachineManager.push(new MainMenuState());private void confirmQuit() {
Menu confirmMenu = new Menu("Confirm")
.addItem("Yes", () -> {
StateMachineManager.clear();
StateMachineManager.push(new MainMenuState());
})
.addItem("No", () -> StateMachineManager.pop())
.setBorder(true);
// Push temporary state
StateMachineManager.push(new LoopableState() {
@Override public void start() { confirmMenu.focus(); }
@Override public void handleInput() { /* handle */ }
@Override public void render() { confirmMenu.render(RenderManager.UI_LAYER + 1); }
@Override public void end() { confirmMenu.unfocus(); }
@Override public void pause() {}
@Override public void resume() {}
@Override public void update() {}
});
}public class SplashState implements LoopableState {
private int frameCount = 0;
private static final int SPLASH_DURATION = 120; // frames
@Override
public void start() {
// Show splash screen
}
@Override
public void update() {
frameCount++;
if (frameCount >= SPLASH_DURATION) {
StateMachineManager.replace(new MainMenuState());
}
}
@Override
public void render() {
// Render splash
}
// ... other methods
}Symptom: Input not working in state
Solutions:
- Check input context is set in both
start()andresume() - Verify state is actually active:
StateMachineManager.active() - Check
renderReady()returns true - Ensure no exception in
handleInput()early-returns
Symptom: Previous state's visuals remain visible
Solutions:
- Call
RenderManager.clearAll()inresume() - Implement
clearUILayers()helper and call inrender() - Clear specific layers before rendering
Symptom: Memory usage grows over time
Solutions:
- Null all object references in
end() - Unfocus UI components before nulling
- Clear collections (
labels.clear()) - Remove entities from EntityPool
Symptom: Keys don't work after returning from overlay
Solution: Always re-set context in resume():
@Override
public void resume() {
InputManager.setContext(new MyInputContext()); // ✅
}Symptom: Unexpected state transitions
Solution: Add logging to track stack changes:
@Override
public void start() {
LOGGER.info(this.getClass().getSimpleName() + " started");
}
@Override
public void end() {
LOGGER.info(this.getClass().getSimpleName() + " ended");
}Symptom: NPEs or missing visuals on startup
Solution: Always check renderReady():
@Override
public void handleInput() {
if (!renderReady()) {
return;
}
// ... safe to proceed
}- Architecture Documentation: See
ARCHITECTURE.mdfor overall system design - Input System Guide: See
INPUT_SYSTEM_GUIDE.mdfor input handling - UI Components Guide: See
UI_COMPONENTS_GUIDE.mdfor UI components - Developer Guide: See
DEVELOPER_GUIDE.mdfor development workflow
Source Code References:
- State machine implementation:
src/main/java/net/luxsolari/engine/systems/internal/StateMachineCoordinator.java - State manager facade:
src/main/java/net/luxsolari/engine/manager/StateMachineManager.java - State interface:
src/main/java/net/luxsolari/engine/states/LoopableState.java - Example states:
src/main/java/net/luxsolari/game/states/
Last Updated: 2025 For Console Jack - Terminal-based Blackjack Game