Skip to content

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.

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 = 0

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

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 = 32

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

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 = 0

Give 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 handshake

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