Pulsar Online was an old browser space game that I developed 20 years ago. For most of its life it was turn-based: every move, dock or jump cost a unit of a resource called antimatter, which slowly refilled over the day. That model is simple to build. A request comes in, you check the balance, take one off and apply the effect. Everything happens in one request.
This game is coming back online as Starward Drift later this year! A few finishing touches on the balancing side and it will be ready. I wonder how much nostalgia internet has for early 2000’s browser RPGs!

It also plays badly. A player with a full tank could cross a whole star system in a few seconds of clicking, then had nothing to do for hours. So in attempt to update it for “modern audiences” U dropped turn-based approach entirely and made actions cost time instead. Moving to the next sector takes as long as the ship’s speed allows. Docking takes time. Gathering ore takes longer the more you scoop. Refitting a weapon takes time.
“Wait N seconds, then do the thing” sounds trivial. In a stateless web application running on several PHP containers, with players who double click, open a second tab and leave the game in a background tab, it isn’t. This post covers the design we ended up with: two requests per action and one Redis key per player.
The requirements
- The server decides when the action happens. The client can start a countdown, but it must not be able to shorten it.
- No errors after a wait. If an action is going to fail (“you’re docked, you can’t move”), the player should hear it right away, not after a 30-second countdown.
- Exactly once. A double click, a retry or a second tab must never execute the same move twice.
- One clock. Requests land on different containers whose clocks drift. The cooldown can’t depend on which one you hit.
- No permanent lockout. If the client crashes, loses its network or the tab gets throttled, the player must not be stuck “mid-action” forever.
- No long-running requests. Holding an HTTP request open for 30 seconds of travel ties up a PHP worker per player. I wanted ordinary short requests.
The shape: intent, wait, commit
Every timed action is split into two requests:
- The intent (
shipMove,shipDock,gather, …) runs only cheap validity checks, works out how long the action takes, and records “this player wants to do X, and may do it at time T.” - The commit (
cooldownCommit) arrives after the wait. The server checks that T has passed, removes the record and runs the real action, which we call the execute step (shipMoveExecute, …).
Between the two, the client just waits. It shows a progress bar, animates the ship sliding to the next tile, and sends the commit when the countdown ends.
The intent and the execute step are separate actions in the dispatcher, listed in one table:
public const ACTIONS = array(
'shipMove' => 'shipMoveExecute',
'shipNodeJump' => 'shipNodeJumpExecute',
'shipDock' => 'shipDockExecute',
'shipUnDock' => 'shipUnDockExecute',
'engageFtl' => 'engageFtlExecute',
'gather' => 'gatherExecute',
'jettison' => 'jettisonExecute',
// ...
);Two rules follow from it. First, an execute action is never accepted from the client. The engine rejects it outright, so the only way to reach shipMoveExecute is through a successful commit:
if (\Gameplay\ActionCooldown::isExecuteAction($dispatchAction)) {
throw new \Gameplay\SecurityException();
}Second, while an action is pending, only read-only actions go through (opening the map, the cargo hold, another player’s profile). Anything that writes gets a “you’re busy” warning. Otherwise a player could, say, sell their cargo halfway through docking.
Cheap checks first, full checks later
The intent deliberately does as little as possible:
case 'shipMove':
if ($shipPosition->isDocked()) {
throw new \Gameplay\SecurityException();
}
$canMove = match ($dispatchSubaction) {
'up' => $shipPosition->Y > 1,
'down' => $shipPosition->Y < $system->Height,
'left' => $shipPosition->X > 1,
'right' => $shipPosition->X < $system->Width,
default => false,
};
if (!$canMove) {
throw new \Gameplay\SecurityException();
}
$travelTimeMs = $shipProperties->travelTimeMs((int) $sector->MoveCost);
if (\Gameplay\ActionCooldown::begin($userId, $dispatchAction, $dispatchSubaction, $travelTimeMs) === null) {
// another action is already pending
$envelope->announcementPanel->write('warning', $t->get('cooldownActive'));
}
break;
The execute step then re-checks everything, because the world may have changed during the wait. The ship could have been EMP’d, an NPC could have attacked, or the ore the player is gathering could have been taken by someone else. Random rolls such as the EMP malfunction chance happen at execute time too, so the player can’t see the outcome and abort.
This split satisfies requirement 2 without giving up correctness. Errors you can detect early come back immediately, and anything that depends on state at execute time is decided at execute time.
Some actions need values fixed at intent time. Gathering, for example, decides how many units to scoop and computes the wait from that number. Those values are stored with the intent and handed to the execute step by the commit, never re-read from the client:
// gather intent
ActionCooldown::begin($userId, 'gather', $type, $gatherTimeMs, ['id' => $id, 'units' => $units]);
// gather execute: at most the planned units, and at most what is still there
$toGather = min(self::planGather($playerEnvelope, $type, $id, false), (int) ($params['units'] ?? 0));If the client could resend units on commit, it could ask for 1 unit (short wait) and commit 500.
One key per player: SET NX PX
The whole pending state is a single Redis string per player:
gameplay:actionCooldown:{userId} = {"action":"shipMove","subaction":"right",
"params":{},"durationMs":2400,"readyAt":1791544211337}
begin() writes it with one command:
$value = json_encode([
'action' => $action,
'subaction' => $subaction,
'params' => $params,
'durationMs' => $durationMs,
'readyAt' => self::nowMs() + $durationMs,
]);
if ($redis->set($key, $value, 'PX', $durationMs + $config['commitWindowMs'], 'NX') === null) {
return null; // something is already pending
}The two flags do most of the work:
NX(set only if absent) makes “one action at a time” atomic. Two intents racing from two tabs can’t both start: exactly oneSETwins, and the other getsnulland a warning. There’s no read-then-write window.PX(expiry in ms) handles requirement 5. The key lives for the action’s duration plus a commit window (10 s). If the client never commits, because it crashed, went offline or the tab was frozen, Redis deletes the key on its own and the player is free again. Nothing needs cleaning up and no cron job is involved.
The commit window is generous on purpose. Browsers throttle setTimeout in background tabs heavily, sometimes to once a minute, and mobile networks add latency. A tight window would turn “I switched tabs during a jump” into “my jump silently vanished.”
Here is the key’s whole life:
One clock: Redis TIME
readyAt is an absolute timestamp, so whoever compares against it must agree on what “now” is. Our PHP runs in several containers, and their clocks are close but not identical. A few hundred milliseconds of drift is enough to make a 2-second scan feel randomly early or late, depending on which container served the commit.
So PHP never uses its own clock for cooldowns. “Now” always comes from Redis:
private static function nowMs(): int {
$time = self::redis()->time(); // [seconds, microseconds]
return (int) $time[0] * 1000 + intdiv((int) $time[1], 1000);
}Every container asks the same server, so there is one clock. As we’ll see next, the readiness check runs inside Redis anyway, which removes the last chance of a mismatch.
Exactly once: claiming with a Lua script
The commit has to do three things: read the pending intent, check it’s ready, and delete it. If you do those as three separate commands, two commits arriving together (a double click, or two tabs whose timers fire at the same moment) can both read the key before either deletes it, and both execute the move.
GETDEL would make read and delete atomic, but then a commit that arrives too early would delete an intent it shouldn’t. We need conditional deletion, so the whole claim runs as one Lua script. Redis executes scripts atomically:
local value = redis.call('GET', KEYS[1])
if not value then
return nil -- nothing pending
end
local pending = cjson.decode(value)
local toleranceMs = tonumber(ARGV[1])
local durationMs = tonumber(pending['durationMs'])
if durationMs then
toleranceMs = math.min(toleranceMs, math.floor(durationMs / 2))
end
local time = redis.call('TIME')
local remainingMs = pending['readyAt']
- (tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000))
if remainingMs > toleranceMs then
return {0, remainingMs} -- too early, keep the intent
end
redis.call('DEL', KEYS[1])
return {1, value} -- claimed: only one caller ever gets thisThe losing commit isn’t an error. It returns the current state, and the client resyncs from it. Because the script deletes only the value it just read, a slow claim can never delete a newer intent that was stored after the old one expired.
In the engine, the commit is just a redirect:
case 'cooldownCommit':
$claimed = \Gameplay\ActionCooldown::claim($userId);
if (is_int($claimed)) {
$cooldownTooEarly = true; // tell the client how long to wait
} elseif ($claimed !== null) {
$redirect = $claimed; // ['action' => 'shipMoveExecute', 'subaction' => ..., 'params' => ...]
}
break;The dispatcher loops on $redirect, so the execute action runs in the same request, with the stored params arriving as $dispatchParams.
The tolerance bug
The client’s timer and the network are never perfectly aligned with Redis’s clock. A commit can arrive a few milliseconds before readyAt. Answering “too early, wait 3 ms” and making the client do another round trip just adds visible lag. So we accept commits up to toleranceMs (50 ms) early.
That worked until I made jettisoning cargo a timed action, at 50 ms per product unit. A single-unit jettison has a 50 ms cooldown. With a 50 ms tolerance, the intent is claimable the instant it’s created. A client could send intent and commit back to back, and the cooldown simply didn’t exist for small actions. Worse, a scripted client could chain them.
The fix is the two lines in the script above: tolerance is capped at half the action’s own duration.
toleranceMs = math.min(toleranceMs, math.floor(durationMs / 2))Long actions keep the full 50 ms of slack. A 50 ms action gets 25 ms, so it still has to wait at least half its time. That’s why durationMs is stored in the key: the claim needs to know how long the action was meant to take, not just when it ends.
The general lesson: a fixed tolerance is a fixed discount. For any action shorter than the tolerance, the discount is 100 %. Make it relative to what it’s discounting.
The client: wait, commit, resync
The browser holds no authoritative state. Every engine response, whatever the action, carries the current pending cooldown, read fresh from Redis:
$pendingAction = \Gameplay\ActionCooldown::pending($userId);
if ($pendingAction !== null) {
$pendingAction['tooEarly'] = $cooldownTooEarly;
$envelope->cooldown = $pendingAction; // {action, subaction, durationMs, remainingMs, tooEarly}
}
The client reacts to whatever it’s told:
function fcHandleCooldown(cooldown) {
if (fcCooldown.timer !== null) {
clearTimeout(fcCooldown.timer);
fcCooldown.timer = null;
}
if (!cooldown) {
// nothing pending: unlock the controls
// ...
return;
}
// lock the controls, draw progress and animate the move
$('#fcPad button, #fcActions button, ...').addClass('is-disabled').attr('disabled', 'disabled');
fcCooldownProgress(cooldown);
fcAnimateMove(cooldown);
fcCooldown.timer = setTimeout(function () {
fcCooldown.timer = null;
executeAction('cooldownCommit');
}, Math.max(0, cooldown.remainingMs));
}
This gives a few properties for free:
- Page reloads just work. Reload in the middle of a 30-second jump and the first response says “shipNodeJump pending, 18 400 ms left”. The progress bar resumes at the right place, because the response sends both
durationMsandremainingMs, and the commit fires on time. - Too-early commits correct themselves. If the client’s timer fires early, the response still carries the cooldown with the new
remainingMs, and the client simply schedules another commit. - Lost commits are harmless. If the commit never arrives, the key expires after the commit window and the next response carries no cooldown, which unlocks the UI.
- A tampered client gains nothing. Committing early gets “too early”. Committing twice gets one execution. Calling the execute action directly gets a security exception. Resending different parameters is ignored, because they come from the stored intent.
What it cost
The design isn’t free:
- Two round trips per action instead of one. In practice the second one is hidden behind the animation the player is already watching.
- Every timed action is written twice, as an intent and an execute step, and the execute step duplicates the intent’s checks. We accepted that duplication on purpose. The alternative, trusting checks made seconds earlier, is exactly the class of bug the split exists to prevent.
- A list of read-only actions to maintain. Adding a new read-only screen means adding it to
READ_ONLY_ACTIONS. Forget, and players can’t open it while travelling. It fails closed, which is the right direction, but it’s a list someone has to remember. - Redis becomes part of every request. It already was (cache, config overrides, realtime pub/sub), so that cost was already paid.
Adding a new timed action
With the framework in place, a new timed action is a short checklist:
- Add
'intent' => 'intentExecute'toActionCooldown::ACTIONS. - For a fixed duration, add it to
$config['cooldown']['durationMs']. For a computed one, pass it tobegin(). Pass anything the execute step needs as$params. - Split the handler into an intent case (cheap checks, then
begin()) and an execute case (full checks, then the effect). - If the console needs special visuals, handle them in
fcHandleCooldown.
Equipping weapons from cargo, repairing the ship and reloading weapons were all added this way. None of them needed changes to the cooldown code itself.
Summary
| Problem | Solution |
|---|---|
| Server must own the timing | Absolute readyAt stored server-side; the client only waits |
| No errors after a wait | Cheap checks in the intent, full re-check in the execute step |
| One action at a time | SET … NX |
| Exactly once | Lua claim: GET + readiness check + DEL atomically |
| Clock drift between containers | Redis TIME for every “now” |
| Crashed or throttled clients | PX = duration + generous commit window |
| Network jitter | Early-commit tolerance, capped at half the duration |
| Client tampering | Execute actions unreachable directly; parameters fixed at intent time |
The whole mechanism is one PHP class of about 150 lines, one Lua script and one Redis key per player. Most of the design work went into deciding what not to trust, whether that’s the client, the clock, or state read a few seconds earlier.






