Stage 4: pack state
The Pack so far is one-directional: redeems go in, nothing comes back out. Pack state is the outbound half of the data plane — keys the adapter writes and the platform fans out (an overlay widget can subscribe to each one). We add two, each showing a different facet.
A counter that survives restarts
Section titled “A counter that survives restarts”effect_count is the lifetime chaos tally — bumped on every applied effect:
[state.effect_count]writer = "adapter"persist = true[state.effect_count.params_schema]type = "integer"minimum = 0Writes are fire-and-forget state_write frames, independent of any
Invocation lifecycle:
send(sock, {"mpp": 2, "type": "state_write", "key": "effect_count", "value": value})persist = true is the interesting part. Without it, an adapter restart
resets the tally to zero. With it, the bridge durably stores the last value
and replays it in hello_ack’s persisted[] — so right after the
handshake, seed your counter:
def seed_effect_count(persisted): global _effect_count for entry in persisted: if entry.get("key") == "effect_count": _effect_count = int(entry.get("value") or 0)
ack = json.loads(rfile.readline())# ...seed_effect_count(ack.get("persisted", []))Order matters: apply persisted[] before your first state_write, or
last-write-wins clobbers the seeded value with a fresh zero.
A key that mirrors the game
Section titled “A key that mirrors the game”active_effect holds the name of the timed effect currently running, or
"" when none — exactly what an overlay banner (“MOON GRAVITY ACTIVE”)
wants to subscribe to:
[state.active_effect]writer = "adapter"[state.active_effect.params_schema]type = "string"maxLength = 32The timed-queue handler from stage 3 writes it at both edges of the effect:
# after sending `applied`:send(sock, {"mpp": 2, "type": "state_write", "key": "active_effect", "value": event})
# inside revert(), before sending `done`:send(sock, {"mpp": 2, "type": "state_write", "key": "active_effect", "value": ""})Because the timed queue serializes effects, these writes can never
interleave — another thing ready_after = "done" buys for free.
A key the game writes for you
Section titled “A key the game writes for you”The first two keys mirror things the adapter did. The best state comes
from the game itself — and the RCON connection already delivers it: the
server pushes its console lines to authenticated RCON clients as
SVRC_MESSAGE datagrams, and player deaths appear there as obituaries
(“Hermes was splattered by a cyberdemon.”).
[state.deaths]writer = "adapter"[state.deaths.params_schema]type = "integer"minimum = 0Give the RCON client a message callback (dispatch it outside the client’s internal lock — a callback that sent an RCON command back would deadlock), and count the lines that look like obituaries:
_OBITUARY_RE = re.compile( r"^\S+ (was \S+.* by |died|suicides|melted|should have stood back" r"|stood in awe|can't exit|chewed on|fell too far" r"|tried to leave|went boom)")
def track_deaths(sock): def on_message(line): global _deaths if not _OBITUARY_RE.search(strip_color_codes(line).strip()): return _deaths += 1 send(sock, {"mpp": 2, "type": "state_write", "key": "deaths", "value": _deaths}) return on_message
rcon.on_message = track_deaths(sock) # after the MPP handshakeTwo honest caveats, both tutorial-worthy:
- It’s a heuristic. The pattern matches the stock English obituary strings; a custom-language server or a total-conversion WAD with bespoke obituaries needs a game-side hook (ACS) for exact counts. Shipping the pragmatic version first and noting its limits beats blocking on perfect.
- It’s a third writer thread. Frames now leave the adapter from the
main loop, revert timers, and the RCON pong thread — put a lock around
your
send()so NDJSON lines can’t interleave.
No persist on this one: a death tally is a per-session stat, and letting
it reset on restart is the correct behavior — which is exactly why
persist is opt-in per key.
Next: the inbound mirror — settings the broadcaster pushes to the adapter.