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 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.