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.
The overlap problem
Section titled “The overlap problem”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 = falserequired = ["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 = 5maximum = 120default = 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 = 5maximum = 120default = 30On 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.
The handler
Section titled “The handler”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 handlerWhat you get for free
Section titled “What you get for free”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.
See also
Section titled “See also”- Dispatch semantics — the full
ready_aftercontract.