Building a Queue-Driven Game Engine

Building a Queue-Driven Game Engine

Most game engines use a traditional game loop - a continuous cycle that updates the game state 30 or 60 times per second. For Void Ward, we took a completely different approach using Laravel's job queue system.

Why Queues Instead of Loops?

Traditional game loops work great for single-player games or small multiplayer sessions, but they have scaling limitations:

  1. Single point of failure - if the loop crashes, the entire game stops
  2. Hard to scale horizontally - you can't easily add more servers
  3. Resource intensive - runs constantly even when nothing is happening

Our Queue-Driven Approach

Instead of a loop, every game action in Void Ward is a Laravel Job:

  • MoveShipJob - Processes a single tile movement, then re-queues itself for the next movement
  • MineNodeJob - Handles a mining cycle with delay based on player mining level
  • AttackJob - Handles combat actions and damage calculation
  • NpcThinkJob - Controls NPC AI and behavior (2-4s intervals)
  • GateBuildJob - Processes player contributions to gates and creates new sectors

Benefits

This architecture gives us some huge advantages:

  • Horizontal scaling - add more worker nodes to handle more players
  • Natural rate limiting - jobs execute with realistic delays (mining takes 5-30 seconds)
  • Fault tolerance - if a job fails, only that specific action is affected
  • Persistence - unfinished actions survive server restarts
  • Observability - every action is logged and trackable

Data Flow

  1. Player sends action via HTTP/WebSocket
  2. Controller validates and queues appropriate Job
  3. Job executes after specified delay (simulating game time)
  4. On completion, broadcasts WebSocket events via Reverb
  5. Client updates UI based on WebSocket events

The queue acts as our "physics engine" - ensuring actions happen in the right order with realistic timing.

Real Example

When a player starts mining:

// Player clicks mine button
MineNodeJob::dispatch($player, $resourceNode)
    ->delay(now()->addSeconds($miningDelay));

The job waits for the appropriate time, then:

// Extract resources
$player->addToCargoHold($extractedOre);
$resourceNode->reduceDurability();

// Broadcast to all players in sector
broadcast(new ResourceMinedEvent($player, $ore));

// Continue mining if player hasn't moved
if ($player->still_mining) {
    MineNodeJob::dispatch($player, $resourceNode)
        ->delay(now()->addSeconds($nextMiningDelay));
}

This creates a natural, persistent mining loop that survives server restarts and scales across multiple workers.

Challenges

This approach isn't without challenges:

  • Complexity - reasoning about distributed state is harder than a simple loop
  • Debugging - tracking issues across multiple queued jobs requires good tooling
  • Race conditions - multiple jobs affecting the same entity need careful coordination

But for an MMO that needs to scale and handle thousands of concurrent players, the benefits far outweigh the complexity.

Next up: How we handle collision detection with Redis bit-sets...

Join the Fleet

Stay updated on development progress and be among the first to play when we launch!