Skip to content

Stage 3: timed effects

Now the headline effects: moon gravity and nightmare-speed monsters. Both set a server cvar (sv_gravity, sv_fastmonsters), last a while, and revert.

Timed effects raise a question instant ones don’t: what happens when two arrive at once? You don’t want the second gravity redeem to revert early, stack, or vanish. You could track overlap in the adapter — or you can let the bridge do it. That’s exactly what the second queue policy is for:

[queues.timed]
ready_after = "done"
[events.gravity]
title = "Change gravity"
summary = "Set the arena's gravity for a while, then revert to normal."
queue = "timed"
[events.gravity.params_schema]
type = "object"
additionalProperties = false
required = ["level"]
[events.gravity.params_schema.properties.level]
type = "string"
enum = ["moon", "heavy"]
"x-mobrule-viewer-override" = true
[events.gravity.params_schema.properties.seconds]
type = "integer"
minimum = 5
maximum = 120
default = 30
[events.fast_monsters]
title = "Fast monsters"
summary = "Nightmare-speed monsters for a while."
queue = "timed"
[events.fast_monsters.params_schema]
type = "object"
additionalProperties = false
[events.fast_monsters.params_schema.properties.seconds]
type = "integer"
minimum = 5
maximum = 120
default = 30

On a ready_after = "applied" queue, the slot frees as soon as you send applied. On a ready_after = "done" queue, the slot frees only at done — so the adapter sends applied the moment the effect is visible (the viewer got what they paid for), and holds done until the effect reverts.

Note what seconds is not: viewer-overridable. The broadcaster sets it per reward in the Cue→Event mapping, so “Moon Gravity (30s)” and “Moon Gravity (60s)” can be two rewards at different prices — duration is part of the reward’s identity, not a viewer choice and not a global setting.

GRAVITY = {"moon": 150, "heavy": 2000}
GRAVITY_NORMAL = 800
def handle_timed_queue(rcon):
def handler(sock, inv_id, event, params):
seconds = int(params.get("seconds", 30)) # per-reward duration
if event == "gravity":
level = params["level"]
cvar, value, revert_value = "sv_gravity", GRAVITY[level], GRAVITY_NORMAL
result = {"level": level, "seconds": seconds}
elif event == "fast_monsters":
cvar, value, revert_value = "sv_fastmonsters", 1, 0
result = {"seconds": seconds}
else:
failed(sock, inv_id, f"unknown event: {event}")
return
try:
rcon.command(f"{cvar} {value}")
except RconError as e:
failed(sock, inv_id, str(e))
return
send(sock, {"mpp": 2, "type": "applied", "id": inv_id,
"result": result})
def revert():
try:
rcon.command(f"{cvar} {revert_value}")
except RconError as e:
# Best-effort: the server may be gone. The invocation still
# completes so the queue never wedges.
print(f"revert {cvar} failed: {e}")
send(sock, {"mpp": 2, "type": "done", "id": inv_id})
t = threading.Timer(seconds, revert)
t.daemon = True
t.start()
return handler

The bridge holds the next timed Invocation until the current one’s done arrives. One timed effect active at a time, queued redeems wait their turn, and the adapter needs zero overlap bookkeeping — no timer dedup, no “is gravity already weird?” state. Meanwhile chat and effects keep flowing, because queues are independent.

One subtlety: the revert path always sends done, even when the revert command fails. A ready_after = "done" queue whose done never arrives is a wedged queue.

Next: publish what’s happening in the game back over the data plane.