Building a Queue-Driven Game Engine
Building a Queue-Driven Game Engine
Most game engines run a traditional game loop: a continuous cycle that updates game state 30 or 60 times a second. Void Ward takes a different approach, built on Laravel's job queue.
Queues, not loops
Game loops suit single-player games and small multiplayer sessions, but they hit scaling limits:
- Single point of failure: if the loop crashes, the whole game stops
- Hard to scale horizontally: you can't add servers on demand
- Wasteful: it 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: commits a tile of movement, then re-queues itself for the next one
- MineNodeJob: runs one mining cycle, with a delay set by your mining level
- AttackJob: resolves combat actions and damage
- NpcThinkJob: drives NPC behaviour (2-4s intervals)
- GateBuildJob: tallies player contributions to a gate and creates the new sector
Benefits
This approach buys us several things:
- Horizontal scaling: add worker nodes to handle more players
- Natural rate limiting: jobs run with real delays (mining takes 5-30 seconds)
- Fault tolerance: a failed job affects only that one action
- Persistence: unfinished actions survive server restarts
- Observability: every action is logged and traceable
Data Flow
- Player sends an action via HTTP/WebSocket
- Controller validates it and queues the right job
- Job runs after its delay, simulating game time
- On completion, it broadcasts events over Reverb
- Client updates the UI from those events
The queue is our physics engine: it keeps actions in 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 out its delay, 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));
}
That gives us a persistent mining loop that survives restarts and spreads across workers.
Challenges
The trade-offs are real:
- Complexity: distributed state is harder to reason about than a single loop
- Debugging: tracing a problem across many queued jobs needs good tooling
- Race conditions: several jobs touching the same entity need careful coordination
For an MMO aiming at thousands of concurrent players, that complexity is worth paying.
Next up: how we handle collision detection with Redis bit-sets...
Be on the bridge when the first gate opens.
One email when the alpha opens its doors. The build log stays public until then.
You're on the manifest. One transmission when the gates open.