Skip to content

Game loop & time

The server has no global tick counter and no loop running at a fixed interval. All game time is managed by a single component, the Scheduler, a queue of dated events. This page explains how time is measured, how the -f (frequency) parameter affects speed, and why this design avoids any busy-waiting.

The Scheduler: a heap of dated events

The Scheduler is a min-heap (std::priority_queue ordered by ascending deadline) of events. Each event pairs an absolute deadline (steady_clock) with a callback.

// scheduler/Event.hpp:16-21
struct Event {
    std::chrono::steady_clock::time_point deadline;
    std::function<void()>                 callback;

    bool operator>(const Event& o) const { return deadline > o.deadline; }
};
// scheduler/Scheduler.hpp:32-35 (excerpt)
int _freq;
std::priority_queue<Event, std::vector<Event>, std::greater<Event>> _queue;

Using std::greater makes the top of the queue the event closest in time — exactly what we want to consult continuously.

Scheduling an action: schedule(cb, delayTicks)

A deferred action (the completion of a command after its delay, death by starvation, resource respawn) is registered via schedule. The delay is expressed in time units (ticks); the Scheduler converts them into milliseconds according to the frequency.

// scheduler/Scheduler.cpp:9-18
void Scheduler::schedule(std::function<void()> cb, int delayTicks)
{
    Event event;

    event.deadline = std::chrono::steady_clock::now()
        + std::chrono::milliseconds((delayTicks * 1000) / _freq);
    event.callback = cb;

    _queue.push(event);
}

The conversion is (delayTicks * 1000) / freq milliseconds. In other words:

$$ \text{1 time unit} = \frac{1000}{freq}\ \text{ms} $$

Effect of the frequency

With -f 100 (default value), one time unit lasts 1000 / 100 = 10 ms. A Forward command (delay 7) therefore completes after 70 ms.

With -f 1000, a unit lasts only 1 ms, and the same Forward completes after 7 ms. The higher -f, the faster the game: the frequency is a game-time acceleration factor. For the list of per-command delays, see Commands & delays.

Running due actions: tick()

On each poll() wake-up, the main loop calls Scheduler::tick(), which pops and runs all events whose deadline has passed:

// scheduler/Scheduler.cpp:20-30
void Scheduler::tick()
{
    auto now = std::chrono::steady_clock::now();

    while (!_queue.empty() && _queue.top().deadline <= now) {
        Event event = _queue.top();
        _queue.pop();

        event.callback();
    }
}

Computing the poll() timeout: nextDeadlineMs()

This is the heart of the design. Rather than polling in a loop, the server asks the Scheduler how long it can sleep before something must happen, and passes that value as the poll() timeout.

// scheduler/Scheduler.cpp:32-44
int Scheduler::nextDeadlineMs() const
{
    auto now = std::chrono::steady_clock::now();

    if (_queue.empty())
        return -1;          // no event → poll() blocks indefinitely

    auto diff = std::chrono::duration_cast<std::chrono::milliseconds>(
        _queue.top().deadline - now
    ).count();

    return std::max(0, static_cast<int>(diff));
}
  • Empty queue → -1: poll() blocks indefinitely until network activity (the server truly sleeps, 0% CPU).
  • Otherwise → the number of milliseconds until the next event (never negative).

A full iteration

sequenceDiagram
    participant Loop as Main loop
    participant Sched as Scheduler
    participant Poll as poll()
    participant Disp as AICommandDispatcher

    Loop->>Loop: updatePollEvents()
    Loop->>Sched: nextDeadlineMs()
    Sched-->>Loop: timeout (ms)
    Loop->>Poll: poll(_pollFds, timeout)
    Poll-->>Loop: ready fds (or timeout)
    Loop->>Loop: readClient / writeClient / accept
    Loop->>Sched: tick()  (run due events)
    Note over Sched: completed commands,<br/>hunger, respawn…
    Loop->>Disp: for each idle AI, popNextCommand()
    Loop->>Sched: schedule(response, cmd->getDelay())
    Note over Loop: back to top of loop

Concretely, the loop (Server.cpp:111-...):

  1. recomputes the poll events (POLLIN always, POLLOUT on demand);
  2. fetches the timeout via nextDeadlineMs() and calls poll();
  3. handles the ready descriptors (accept, read, write);
  4. calls scheduler->tick() to run the actions that have reached their deadline;
  5. for each idle AI client (neither busy nor incanting), pops one command and schedules its completion after cmd->getDelay() ticks via schedule.
// core/Server.cpp:114-115 and 161
int timeout = _scheduler->nextDeadlineMs();
int pollResult = poll(_pollFds.data(), _pollFds.size(), timeout);
...
_scheduler->tick();

Why this design avoids busy-waiting

No CPU wasted

A naive game loop would poll the clock continuously (while (true) { if (now >= deadline) … }), burning CPU for nothing. Here, the server sleeps inside poll() exactly until the next deadline — or indefinitely if there is none. It wakes up for only two reasons: network activity, or a game action to run. The result is a server that is both frugal (0% CPU at rest) and accurate to the millisecond.

Note the absence of a global tick counter: the game's "time" exists only as absolute deadlines stored in the heap. It is purely deadline-driven, not clocked.

Changing the frequency on the fly: setFrequency

The graphical client can change the game speed mid-game via the sst command (see Protocol). The server then applies Scheduler::setFrequency, which rescales all in-flight deadlines to preserve the remaining duration expressed in ticks:

// scheduler/Scheduler.cpp:46-79 (excerpt)
void Scheduler::setFrequency(int freq)
{
    ...
    while (!_queue.empty()) {
        Event event = _queue.top();
        _queue.pop();
        // remaining time converted to ticks at the old frequency...
        int remainingTicks = static_cast<int>((remainingMs * _freq) / 1000);
        // ...then re-expressed in ms at the new frequency
        event.deadline = now +
            std::chrono::milliseconds((remainingTicks * 1000) / freq);
        newQueue.push(event);
    }
    _freq = freq;
    _queue = newQueue;
}

Game consistency

Without this rescaling, speeding up the game would only affect new actions; those already scheduled would keep their old duration in milliseconds. By reconverting ticks → ms at the new frequency, a command that had, say, "5 ticks" left to wait will indeed wait 5 × (1000/freq) ms after the change.

For the detail of the delays associated with each command, see Commands & delays.