How it works

RoadRunner “died”, and the visitors bring it back to life with a ritual on the pumpkin patch. The ritual runs in scenes. In every scene the pumpkins go through several stages: a pumpkin moves on to the next one once it has collected its click threshold (it grows with the number of players on the field — “Limits”), and stops at the scene's last stage, which is set by the list HELLOWIN_SCENE_STAGE_MAX. When every pumpkin has reached it, the scene is ready, and the next one is started by the moderator, who is also the owl narrator. In the finale the owl reveals the real numbers of the evening. One pass of the ritual is a round; a reset starts the next one. Players walk their characters around the lawn and shoot down flyers. Everyone sees everyone else's actions in real time, and the metrics go to a public Grafana. Below is what really happens under the hood, with the names of classes, keys, channels and ports taken from the code.

Spiral Framework
A PHP framework: controllers, a DI container, configs, queues, Stempler templates. Here it holds the business logic.
RoadRunner
An application server written in Go. It keeps pools of long-lived PHP workers and gives them plugins: HTTP, KV, locks, the jobs queue, metrics, a bridge to Centrifugo. PHP talks to the plugins over RPC.
Centrifugo
A server for WebSocket connections and channels (pub/sub). It holds the browser connections, while decisions like “let this one in?” or “what to do with this RPC?” it asks the application through a proxy.
Valkey
A Redis-compatible store. It keeps the state of the ritual and the story, the players, flyers, characters and the transition idempotency keys; AOF on a volume, so the state survives a restart.
Prometheus, Grafana
Prometheus pulls the metrics from the services and stores them; Grafana draws a dashboard from them.

Who talks to whom

There is no nginx in front of RoadRunner: its HTTP plugin serves the pages and static files itself on localhost:8080. Its middleware run left to right: http_metrics, headers (security headers), gzip, static (files from public/ with an ETag, so a repeated request gets a 304) and rate_limiter (a token bucket per IP, only for requests that make it to PHP). RoadRunner is not a reverse proxy, so Centrifugo and Grafana are exposed on their own ports: WebSocket on :8000, the dashboard on :3000. The page gets these addresses from the server, built from the host it was opened by. Everything else lives in the internal docker compose network.

The main load is not HTTP but RPC over WebSocket, and the HTTP limiter never sees it. That is why RPCService takes a token from the player's bucket before any method: the bucket lives in the KV store process (RoadRunner's memory) and is changed under the lock rate:{group}:{player}, because KV has no atomic update. Fast clicking gets through, a stream from a script gets, after burst, a rate limited; the moderator is exempt from the limit.

RoadRunner and Centrifugo are linked by two gRPC channels going in opposite directions. Centrifugo proxies client events (connect, rpc) to the centrifuge plugin at app:10001, and the application publishes messages through the Centrifugo gRPC API at centrifugo:10000.

RoadRunner has three pools of PHP workers, two php app.php processes each: http serves the pages, centrifuge answers connect and RPC, jobs runs the transition jobs from the transitions queue (driver memory). RoadRunner hands requests and jobs to a worker through pipes, the process's stdin/stdout. Back to the kv, lock, jobs, metrics and centrifuge plugins the worker goes over goridge RPC on 127.0.0.1:6001.

Flyers, characters and the top shooters are moved by time, not by requests. That is what the service plugin is for: RoadRunner keeps one long-lived process php app.php hellowin:ticker outside the worker pools and restarts it if it crashes. The ticker reaches the plugins over the same RPC as the workers do, and through the Centrifugo gRPC API it also asks for the channel's presence. A second copy (overlap during a restart) is kept aside by the lock ticker, a lease that its owner renews once a second.

The game state lives in Valkey: the ritual and the story, the players and the top shooters, flyers, characters. It is read and written by RoadRunner's KV plugin: the store hellowin with the redis driver on VALKEY_ADDR, which PHP sees as the PSR-16 cache hellowin. The transition idempotency keys hellowin:amo:* and the stored dice responses hellowin:lease:* are written by the workers to the same Valkey directly, via predis (VALKEY_DSN): the spiral/idempotency package needs an atomic SET NX EX, which the KV plugin lacks. The locks (pumpkin:{id}, scene, player:{id}, flyer:{id}, walkers and others) and the queue live in the memory of the RoadRunner process, which is enough as long as there is a single app.

RoadRunner 3 · container app · .rr.yaml Browser centrifuge-js WebSocket host ports no proxy :8080 · :8000 · :3000 Centrifugo v6 :8000 WebSocket · :9000 /metrics :10000 gRPC API http :8080 static · gzip centrifuge proxy :10001 jobs transitions http workers ×2 HomeController centrifuge ×2 ConnectService RPCService jobs ×2 TransitionPumpkin TransitionScene rpc :6001 — kv · lock · jobs.Push · metrics · centrifuge.Broadcast kv hellowin: redis lock pumpkin:{id}, scene, … metrics :2112 /metrics service: ticker hellowin:ticker one process, lock ticker Valkey valkey:6379 AOF + volume hellowin:round hellowin:scene hellowin:pumpkin:* hellowin:story hellowin:shot hellowin:player:* hellowin:top hellowin:flyer:* hellowin:walkers hellowin:amo:* hellowin:lease:* Grafana :3000 Prometheus :9090 HTTP WS :8000/connection/websocket WebSocket :8080 :3000 gRPC proxy :10001 connect, rpc gRPC API :10000 broadcast, presence pipes pipes pipes goridge RPC driver redis · VALKEY_ADDR predis VALKEY_DSN scrape :2112 · 5s scrape :9000/metrics PromQL → :9090
  • gRPC between Centrifugo and RoadRunner, centrifuge.Broadcast
  • to Valkey: the KV plugin and predis
  • metrics: Prometheus scrapes, Grafana queries
  • HTTP, WebSocket, pipes, RPC, jobs.Push

Page load and connection

GET / is plain HTTP. HomeController::index() takes a state snapshot from SnapshotFactory: RitualStore::snapshot() reads in a single kv.MGet hellowin:round, hellowin:scene, the story hellowin:story and hellowin:pumpkin:{id} of every pumpkin, and one more request fetches the characters hellowin:walkers. All of it without locks: the page may lag a click behind, and that is fine. The layout from app/config/hellowin.php and the policy numbers are added, and the template gets the snapshot (Snapshot):

Then the browser opens a WebSocket and passes the player's secret from localStorage in the connection's data: {playerSecret}. Centrifugo itself knows nothing about users and asks the application on connect (connect proxy). ConnectService answers with user, the player's public id derived from the secret (no secret means anonymous), a list channels: ['hellowin'], which is a server-side subscription, and the connection's meta (the moderator role, see “Moderator, characters, flyers”). From then on Centrifugo puts user into every RPC itself: the client does not send its id and cannot pose as someone else. The client never calls subscribe: client-side subscriptions and publications are disabled in config.json, and channel messages arrive in centrifuge.on('publication').

The RPC state returns the same snapshot. Every channel event carries round: if it does not match the client's round, the client has missed a reset and requests the snapshot again; the same happens after a reconnect, when the events sent during the outage are lost.

1. HTML with a snapshot GET / past static pipes kv.MGet, no lock game state Snapshot: ritual, story, characters, layout, now render('home', snapshot + centrifugoUrl from the host) 200 text/html (back through gzip and headers) scene from the snapshot · new Centrifuge(url, {data: {playerSecret}}) 2. WebSocket ws://<host>:8000/connection/websocket connect proxy (gRPC) pipes user, channels, meta response subscribe to hellowin (server-side) connected + subs: hellowin 3. Resync: RPC state reconnected, or an event came with another round rpc('state', {}) over the same WebSocket RPC proxy (gRPC) pipes SnapshotFactory::create() → kv.MGet ritual, story, characters {ok: true, state} RPC response {ok: true, state: Snapshot} rebuild the scene from the snapshot Browser centrifuge-js RR middleware :8080 RR http worker pool HomeController → SnapshotFactory RR kv → Valkey hellowin Centrifugo :8000 RR centrifuge :10001 ConnectService RPCService
Solid arrows are requests, dashed ones are responses. Purple ones take the path through Centrifugo.

What happens on a click

A click is not an HTTP request but an RPC over the same WebSocket: centrifuge.rpc('click', {element: 'pumpkin-3'}). Centrifugo proxies it to RoadRunner, which hands the call to a free worker from the centrifuge pool, where RPCService passes it on to ClickHandler::click().

  1. The element is checked against ElementRegistry (ids from app/config/hellowin.php).
  2. RitualMetrics adds 1 to hellowin_clicks_total.
  3. RitualStore::click(), under the lock pumpkin:{id}, increments the pumpkin's clicks in KV (and accepted, the round's accepted clicks, for the reveal in the finale) and right away, under the same lock, reads the round and the scene. The lock covers only this pumpkin: clicks on different pumpkins do not wait for each other.
  4. PlayerService, under the lock player:{id}, adds the click to the stats of the player, the connection's user; an anonymous click skips the stats. A stats failure is logged and does not break the click.
  5. ChannelPublisher publishes a click event to the channel hellowin (with player unless the click is anonymous), to all subscribers at once.
  6. LockstepPolicy::pumpkinTransition() decides whether the pumpkin should move on: clicks is at least the pumpkin's threshold (the pumpkin limit) and the stage is below the current scene's stageMax. If so, TransitionQueue queues a TransitionPumpkin job, and hellowin_transition_jobs_pushed_total{kind="pumpkin"} grows by 1.
  7. The RPC response is always data: {ok: true} or {ok: false, error: 'unknown element' | 'busy' | 'internal error'}; busy means the pumpkin's lock could not be taken within 5 seconds.

The click itself does not change the stage, it only queues a job. Until the job is done, clicks stays above the threshold, and every next click queues one more identical job. The duplicates are intended: idempotency cuts them off, and the clicks above the threshold are burnt. Details are on the tab “Idempotent transition”.

The browser does not draw its own click locally. The effect appears only when a publication comes from the channel, for the one who clicked just like for everyone else. That is why the screen shows the honest round trip delay browser → server → browser.

click: draw nothing locally rpc('click', {element}) RPC proxy (gRPC) pipes ElementRegistry::find() clicks_total +1 lock.Lock pumpkin:pumpkin-3 lockId get hellowin:pumpkin:pumpkin-3 {stage, clicks, accepted} set {stage, clicks + 1, accepted + 1} get hellowin:round, hellowin:scene round, scene lock.Release centrifuge.Broadcast gRPC API :10000 publication type:'click' the same publication click effect click effect + progress LockstepPolicy::pumpkinTransition(): clicks ≥ threshold and stage < stageMax? yes: jobs.Push TransitionPumpkin {key, round, pumpkin, toStage, player} jobs_pushed +1 RPCResponse {ok: true} RPC response {ok: true} every further click above the threshold is one more TransitionPumpkin with the same key until the transition is done Other tabs channel hellowin Browser (click) centrifuge-js Centrifugo :8000 RR centrifuge :10001 RPCService → ClickHandler RR metrics :2112 RR lock pumpkin:{id} RR kv Valkey RR jobs transitions under the pumpkin lock: read-modify-write
The orange bar on the worker's line is the time it holds the lock pumpkin:{id}. Metrics, publishing and queuing the job happen outside the lock.

Channel messages are JSON strings. The click event:

{"type":"click","round":…,"element":"pumpkin-3","emoji":"🕸️","clicks":…,"threshold":…,"stage":…}

Scenes, pumpkin stages and locks

The engine knows only numbers: round, scene and, for each pumpkin, {stage, clicks}. What it looks like is up to the frontend. When things move is up to the policy — the RitualPolicy interface, implemented by LockstepPolicy: one spot in the code that can be replaced without touching the engine.

0 stageMax(0) stageMax(1) stageMax(N−1) pumpkin stage scene 0 scene 1 … scene N — finale no transitions, clicks only broadcast pumpkins go to stageMax(s) and wait for others stage +1 = click threshold (pumpkin limit) → TransitionPumpkin all at stageMax(0) → ready, wait scene.next → TransitionScene ready in scene N−1 scene.next → finale N = length of HELLOWIN_SCENE_STAGE_MAX, stageMax(s) = its element s; finale cap = stageMax(N−1)
The orange step is the stage cap stageMax(s); circles are pumpkins.

One player on the field, multiplier 1: the threshold is 2 clicks per stage, and this is how a pumpkin goes through a scene:

  1. 0round start
  2. 1after 2 clicks
  3. 22 more
  4. …2 each
  5. stageMaxwaits for the rest

Limits: base, players, multiplier

A pumpkin threshold is not one number for every case: one player clicks through it in a minute, forty in a second. So every number in the ritual — clicks per stage of a pumpkin, the bonfire and the circle, the bar target, both cursed-world targets, FrankenPHP's health — is computed by one formula from the number of players on the field:

limit = max(1, ⌈k × (BASE + PER_PLAYER × (n − 1))⌉) + bonus

When any input changes, LimitsService::settle compares the current scene's limits with the last announced ones and, if they differ, publishes a limits {players, k, burst, limits} event: browsers update their thresholds, the control panel shows the resulting numbers. A limit that drops below the accumulated count takes no progress away: the server itself queues the stage transition (the same idempotent TransitionPumpkin as after a click) or marks the bar and the cursed world ready, without waiting for a click. FrankenPHP keeps its damage: hp = max(1, max − damage).

The same player count grows the flyer wave the moderator launches with the “Launch flyers” button (RPC flyers.burst): ⌈HELLOWIN_FLYER_BURST_BASE + HELLOWIN_FLYER_BURST_PER_PLAYER × (n − 1)⌉ flyers of the current set at once. No cooldown, no cap: as many presses, as many waves.

Why lock a pumpkin

Clicks are handled by several workers in parallel (the centrifuge pool has two), and many people click the same pumpkin. Incrementing the counter is read-modify-write: read {stage, clicks}, +1, write; the KV plugin has no atomic increment. Without a lock two workers read the same value, and one click is lost.

No lock: a race With lock pumpkin:{id} (RitualStore::click) get 2 get 2 set 3 set 3 result 3, not 4: a click is lost Worker 1 pumpkin-3 RR kv clicks Worker 2 pumpkin-3 lock.Lock → lockId lock.Lock: waits get 2 set 3 lock.Release lock acquired get 3 set 4 lock.Release result 4: both clicks counted Worker 1 pumpkin-3 RR kv clicks Worker 2 pumpkin-3
Orange: the worker holds the lock; dashed frame: it waits for it.

Locks are RoadRunner's lock plugin; each operation has its own set. TTL is 5 seconds — the lock expires by itself if a worker dies; waiting for a held one — up to 5 seconds, then RitualBusyException.

OperationLocksWhat is checked under the lock
click, RitualStore::click()pumpkin:{id}nothing: clicks + 1, then reading round and scene
pumpkin transition, advancePumpkin()pumpkin:{id}round is current and stage == toStage − 1
scene readiness, markReady()sceneround and scene are current, this scene's readiness not marked yet
scene transition, advanceScene()sceneround is current and scene == toScene − 1
control panel: finale step, progress pause, updateStory()scenethe story before and after: an event only if it changed
reset, reset()scene and all pumpkin:{id}round == fromRound
player stats, PlayerStoreplayer:{id}nothing: player counters + 1
shot, FlyerStore::shoot()flyer:{id}the flyer key still exists and the flight isn't over — then delete it
character moves and joins, WalkerStore::update()walkersnothing: read-modify-write of one key holding all characters
bar mugs: pouring, “take”, drunk this round, BarStorebar (on “take” — inside walkers)the scene is the bar; a mug is still on the counter — the first to arrive gets it
cursed-world target: mugs, zombies, bonuses, CursedStorecursednothing: round counters + 1; both targets met — readiness mark under scene
rapier strike at FrankenPHP, BossStorebossthe boss is awake and still alive — then health − 1

The bar: the last mug and pouring in batches

The evening starts in the bar (scene 0): no pumpkins there, but a counter where the owl bartender pours beer. The bar shows two more mechanics — the same lock and KV, but on mugs.

The rapier: the magic word, curing, one boss for the whole crowd

The end of the night shows three more mechanics — again a lock, KV and a server that decides on its own.

Round and reset

A reset starts the ritual over: scene 0 (the bar), every pumpkin at stage 0 and clicks 0, round + 1, the story from the start (the progress pause is lifted, the multiplier k stays) and a reset event to everyone. This is done by ResetService::reset(?int $fromRound), called by the “Restart” button on the moderator's control panel (RPC reset {round} — the round the moderator sees) and by the CLI command hellowin:reset [--round=N]. The service takes the scene lock and the locks of all pumpkins, so neither a click nor a transition sees a half-reset ritual, and under them compares the current round with the one being finished. A second reset of the same round sees the new round already and does nothing — idempotency by reset:{round} without a separate key: the state is already under these locks, and a key in Valkey would be a second source of truth about the round.

lock scene, pumpkin:pumpkin-1… lock scene: waits get hellowin:round 1 = fromRound pumpkins {0, 0}, scene 0, round 2 release all locks publish type:'reset', round:2 locks acquired get hellowin:round 2 ≠ fromRound null: round already reset release reset:{round} without the idempotency package: round is compared under the same locks that guard the state Call A hellowin:reset --round=1 RR lock scene, pumpkin:* RR kv Valkey Centrifugo channel hellowin Call B reset(fromRound: 1)
Orange: the call holds the locks; dashed frame: it waits for them.

Idempotent transition

A pumpkin hit its threshold — exactly one transition to the next stage is needed. But clicks don't stop: while the job sits in the queue waiting for a worker, the pumpkin gets clicked n more times, and each click queues TransitionPumpkin again. This is on purpose. A click doesn't need to know whether someone queued the job before it: no “transition in flight” flag, and no resetting it if the job got lost (the queue is in memory; on an app restart jobs vanish — the next click simply queues a new one). The price of simplicity is k + n jobs per transition, and only one must run.

Duplicates are cut off by the spiral/idempotency package. The job class carries an attribute, and IdempotencyInterceptor in the queue's interceptors.consume chain (app/config/queue.php) takes the key from the payload:

#[Idempotent(storage: 'transitions', key: 'payload.key')]
final class TransitionPumpkin extends JobHandler

Who makes up the key

The server builds the key from the meaning of the operation: TransitionQueue::pumpkinKey() — {round}:{pumpkin}:{toStage}, e.g. 1:pumpkin-3:2 — “in round 1 pumpkin 3 moves to stage 2”. All clicks that queue this transition get the same key independently of each other. This differs from the familiar Stripe-style Idempotency-Key:

Deterministic key (here)Random client key (Stripe-style)
Who makes it upthe server, from the operation's datathe client, a UUID per attempt of an action
Where it travelsin the job payload, field keythe request's Idempotency-Key header
Who duplicatesdifferent clicks by different people, unaware of each otherthe same client on retry: timeout, dropped connection
What it protectsone state change: a pumpkin's move to a stageone request attempt: a payment isn't charged twice
Two different intents with one keyimpossible: same key, same transitiona client bug: the server checks the request against the first one

In Valkey the key gets the store prefix and a scope — the handler name, so that keys of different jobs don't collide: hellowin:amo:App\Application\Hellowin\Job\TransitionPumpkin::handle:1:pumpkin-3:2.

How a duplicate is cut off

The transitions store in app/config/idempotency.php is RedisAtMostOnceConfig with the prefix hellowin:amo: and retentionTtl: 300. Before calling the handler, the driver claims the key with one atomic command SET {key} M:{token} NX EX 300. Written — it's the first delivery, the handler runs. The key already existed — a duplicate: the handler isn't called at all, the interceptor returns null, and the job is acknowledged (ACK) — it doesn't fail and doesn't go back to the queue. The marker is deleted neither on success nor on error; it expires by TTL on its own.

1. Duplicates on purpose: one key for k + n jobs click push key=1:pumpkin-3:2 click push key=1:pumpkin-3:2 … ×n same key ×n 2. The first delivery runs, the rest are cut off job 1 job 2 (in parallel) SET … NX EX 300 OK: marker written SET … NX EX 300 nil: key taken duplicate: handler not called null → ACK TransitionPumpkin → TransitionHandler::pumpkin() lock.Lock pumpkin:pumpkin-3 round current and stage == toStage − 1? set stage = toStage, clicks = 0 lock.Release publish type:'pumpkin' — exactly once transitions_total +1 · all at stageMax? → markReady() under lock scene ACK jobs 3…n: SET NX → nil → ACK while the marker lives Clicks over threshold ClickHandler TransitionQueue RR jobs transitions jobs worker A IdempotencyInterceptor jobs worker B IdempotencyInterceptor Valkey hellowin:amo:* RR lock + kv pumpkin:{id} Centrifugo channel hellowin risk window: a crash here — transition lost until TTL expires
Solid arrows are calls, dashed ones are responses and ACKs. The dashed frame on worker A is the window between the marker and the effect.

Why “at most once”

The package has three guarantees, and they are about different things:

GuaranteeHow it worksIf the worker dies midwayWhat it needs
AtLeastOncea lease on the key while the work runs; the key is closed after the effectthe lease expires, a retry performs the effect again — it may happen twicean effect that is safe to repeat
AtMostOnce — chosenthe marker is written before the effect and never deletedthe effect is lost, retries are cut off while the marker lives — 0 or 1 timesan atomic SET NX EX
ExactlyOncethe job record and the effect in one transaction (inbox)the transaction rolls back entirely, a retry performs the effect anewthe effect and the record in one transactional DB

ExactlyOnce is out of reach here: the effect is a KV write through a RoadRunner plugin plus a publication to Centrifugo, and they share no transaction with the marker. Of the remaining two, a double transition is worse: the pumpkin would skip a stage, and the pumpkin event would go out twice. Losing a transition is cheap — the pumpkin stays at the threshold, and people click it on through. Hence AtMostOnce.

The price: if a worker dies between the marker and the stage write (or the pumpkin lock stays held longer than 5 seconds), the transition is lost, and new jobs with the same key are cut off while the marker lives — the pumpkin “gets stuck” at the threshold. So the window is short: retentionTtl is 300 seconds, not the default of a day. After it the next click starts the transition again, and a double transition is prevented by the domain check under the lock — stage == toStage − 1: if the transition did in fact go through, the retry changes nothing.

Scene transition

A scene doesn't switch by itself: the game isn't timed, the moderator runs it. The transition is split into two steps, and each is protected from double execution in its own way.

  1. Readiness. After each pumpkin transition TransitionHandler asks the policy whether all pumpkins have reached stageMax (RitualPolicy::sceneReady()). The last pumpkins can arrive almost at once, and then several workers hear “yes”. The mark is set by RitualStore::markReady() under the scene lock: if in the story hellowin:story the ready field isn't this scene yet, it writes it and returns true; if it already is, false. So the ready event and the owl's lines last-pumpkin (with the player who finished off the last pumpkin) and scene-ready go out exactly once. The scene itself stays the same.
  2. Readiness with the bonfire. From scene 2 on, ready pumpkins are not enough: the middle of the patch also has a bonfire, one element for the whole crowd. While the scene's pumpkins aren't ready, clicking it answers locked and goes nowhere; instead of readiness, the last pumpkin opens the bonfire (a mark in the story under the same scene lock, owl line bonfire-open). Then everyone hits one bonfire lock — hence the busy answers — and past the bonfire threshold (the bonfire limit) they queue a handful of duplicates of one TransitionPumpkin job with the key {round}:bonfire:{toStage}; only one runs, as kind="bonfire" shows in the metrics. The scene becomes ready when the bonfire moves to its last stage of the scene (HELLOWIN_BONFIRE_STAGE_MAX) — the same markReady(), lines bonfire-done and scene-ready. Likewise, in the final scene the crowd draws the magic circle (lock circle, key {round}:circle:{toStage}): a closed circle makes the finale ready, and only then does “Finale step” raise FrankenPHP (without readiness — only with force).
  3. Transition. The moderator presses “Next scene” — RPC scene.next {scene} with the number of the scene they see. StoryService::nextScene() checks against a snapshot that the scene is current, not final and ready (or force came), and queues TransitionScene. A double click, two control panel tabs or two moderators queue two jobs, and that is the same as duplicate clicks on a pumpkin: the key {round}:scene:{toScene} (TransitionQueue::sceneKey()), the same #[Idempotent] with the AtMostOnce guarantee, and inside — the scene lock, the check scene == toScene − 1 and one scene event with the new scene's stageMax and the characters' item. A press that arrives after the transition sees the scene has changed and queues nothing — it just answers with the current one.
1. Readiness: the last two pumpkins finished at once last pumpkin → stageMax, sceneReady(): yes this one too: sceneReady(): yes markReady(round, 1): lock scene not marked → story.ready = 1 → true markReady(round, 1) already marked → false publish ready · owl last-pumpkin · owl scene-ready ready the “Next scene” button waits for a press 2. Transition: the moderator pressed twice scene.next {scene: 1} same again RPC proxy ×2 + connection meta role from meta · scene 1 ready → TransitionScene, both times key 1:scene:2 ×2 {ok, scene: 2} {ok, scene: 2} ×2 job 1 job 2 (in parallel) SET … NX EX 300 OK SET … NX EX 300 nil → duplicate, ACK lock scene · round current and scene == 1? scene = 2, ready cleared lock.Release publish scene {stageMax, item} · owl scene-2 scene: 2 two pumpkins and two presses — one ready mark and one transition: lock scene and key {round}:scene:{toScene} Moderator control panel Centrifugo channel hellowin RPCService → StoryService RR jobs transitions jobs worker A #[Idempotent] jobs worker B #[Idempotent] Valkey hellowin:amo:* RR lock + kv scene, hellowin:story
Orange: a worker holds the scene lock. In phase 1 the workers run TransitionPumpkin, in phase 2 — TransitionScene.

Why the key includes the round

After a reset the ritual goes through the same transitions again: pumpkin 3 heads to stage 2 once more. Without the round the key would match the old one, and while the old marker lives, the new transition would be cut off as a duplicate. With the round it is a new operation: 2:pumpkin-3:2. And jobs from the previous round left in the queue change nothing, even if they get past the marker: the domain check sees the round has changed.

What you see in Grafana

The dashboard's “Transitions: queue and idempotency” section has the panels “Jobs pushed vs transitions applied” (per minute and total, by kind), “Duplicates dropped” = pushed − transitions and “Pumpkin stages”. The harder people click a pumpkin at the threshold, the bigger the gap. You can also see the markers in Valkey itself, along with the remaining TTL:

docker compose exec valkey valkey-cli --scan --pattern 'hellowin:amo:*'
docker compose exec valkey valkey-cli ttl 'hellowin:amo:App\Application\Hellowin\Job\TransitionPumpkin::handle:1:pumpkin-3:2'

Dice: idempotency with a stored response

Before the uprising in the finale, the crowd rolls three dice until someone gets 6-6-6, a PHP version that never existed. The server does the rolling: the client sends the RPC roll {attempt} with its attempt number, and the dice come back in the reply and to everyone in the roll event. The problem is the same as with a pumpkin transition: a repeated request must not cause a second effect. But a duplicate transition can simply be dropped, while a repeated roll needs an answer: the same dice. So the attempt's response is stored, and the retry gets it.

A fair roll

The dice are truly random: DiceRoller calls Randomizer::getInt(1, 6) three times on top of the system CSPRNG, same as random_int. Nobody can predict which attempt will hit 6-6-6, the server included. But the same dice can't be computed a second time either: a roll is drawn once, and the result has to be remembered.

The key and the stored response

The attempt key is {round}:roll:{player}:{attempt}: the round, the player's public id and the attempt number. DiceService::roll runs the roll through the dice store of the spiral/idempotency package, a RedisLeaseConfig lease with the AtLeastOnce guarantee. The first call with a key rolls and stores the response {attempt, dice} for HELLOWIN_DICE_IDEMPOTENCY_TTL s. A repeat of the same key (a double click, a reconnect, a retry after a lost reply) gets the stored response: no new roll, no event, no count. A refusal (busy, not now, bad attempt) is not a response: FailurePolicy::Release frees the key, and the attempt can be rolled once the refusal no longer applies.

The faces of a player's last attempt are also kept in hellowin:dice under the dice lock, in one record together with the attempt number and the time of the next roll. This is a safety net: AtLeastOnce allows a second run if the worker died before storing the response or the response expired. A run under the lock then finds its attempt in the record and returns those faces without rolling again.

Browser roll {attempt: n} RPC dice store {round}:roll:{player}:{n} spiral/idempotency, AtLeastOnce TTL HELLOWIN_DICE_IDEMPOTENCY_TTL stored none DiceService::roll lock dice hellowin:dice → last last attempt and its faces first 6-6-6 → winner under the same lock; the next gets not now n = last + 1 n = last else Retry: response already stored same {attempt, dice}, no roll double click, reconnect, lost reply; no lock, no event, no count: no reroll New roll random_int × 3 → dice, response stored record {attempt, at, dice}, roll event to all, rolls + 1; gap ≥ HELLOWIN_DICE_COOLDOWN_MS Reply lost, attempt in record worker died before storing, or it expired: faces from hellowin:dice, no roll or event bad attempt {last} attempts can't be skipped: client takes last + 1 and rolls again attempt response: dice store (hellowin:lease:*) for HELLOWIN_DICE_IDEMPOTENCY_TTL s; last attempt's faces: also hellowin:dice
Green is a new effect, dashed is the same response with no effect, red is a refusal; orange is work under the lock.

Moderator, characters, flyers

Around the ritual runs a live evening: the moderator drives the story from the control panel, players' characters walk the lawn, birds and spooks fly across the sky, and in the finale the owl lays out the real numbers. Each of these mechanics is one more technique: a role in the connection meta, channel presence, a shared server clock, “first under the lock”, a count derived from state without extra writes.

The moderator role

The moderator opens the page with ?moderator=<secret>; the page puts the secret into localStorage, removes it from the address and passes it in the connection data next to the player secret. ConnectService compares it with HELLOWIN_MODERATOR_SECRET (hash_equals) and on a match puts {moderator: true} into the connection meta. Centrifugo keeps the meta, the client can't see it, and Centrifugo adds it to every RPC proxy call itself, as configured by rpc.proxy.include_connection_meta in config.json. The client sends only data in an RPC and can't grant itself a role by request. With an empty secret in env, nobody has the role.

RPCService reads the role from meta, and for control panel methods (scene.next, finale.step, owl.say, flyer.special, flyers.burst, pause, thresholds, reset) answers forbidden without it, before parsing the data. thresholds {k} multiplies all limits by k (“Limits”) to fit the ritual to the room on the fly; k lives in the story and survives a reset. pause {on} pauses progress: the scene's goal freezes while the room carries on. RPCs that advance the goal (click on pumpkins, the bonfire and the circle, drink, strike, roll) are not refused: the action goes through and the event reaches everyone, but with counted: false: clicks and stages hold, mugs don't count toward the goal, the boss's health stays the same, 6-6-6 doesn't win, and the scene never becomes ready. Players don't see the pause; it shows only on the control panel. Flyers, zombies and shots are not affected by the pause. Lifting the pause catches up through the same recount as a lowered limit, and a reset lifts it by itself. The role is not in user: the moderator can also be a player with their own secret, and user is the player's public id in events and presence. Every control panel method is safe to repeat: StoryService changes the story hellowin:story under the scene lock, compares “before” and “after”, and publishes the event (and an owl line) only if something changed.

1. Connect: secret in data, role in meta connect {data: {playerSecret, moderatorSecret}} connect proxy: data hash_equals(HELLOWIN_MODERATOR_SECRET, …) user, channels, meta {moderator: true} meta stays here, never sent to the client connected (no meta) 2. Control panel RPC: Centrifugo adds meta, not the client rpc('pause', {on: true}) RPC proxy + meta {moderator: true} panel method, role set → StoryService {ok: true, on: true} {ok: true, on: true} rpc('pause', …) RPC proxy + meta {} no role → forbidden, data not parsed {ok: false, error: 'forbidden'} forbidden the client can't grant itself a role: it sends only data in an RPC, meta comes from Centrifugo Moderator browser + secret Player no secret Centrifugo include_connection_meta RR centrifuge ConnectService · RPCService
Solid arrows are requests, dashed are replies. Purple ones are proxy calls from Centrifugo.

Characters on the lawn: presence and the ticker

A player who has built a character on the entry screen (RPC register with a name and look) is a character on the lawn. The server doesn't track who is on the page by itself: the hellowin channel has Centrifugo presence enabled ("presence": true in config.json, closed to clients). Every connection is subscribed to the channel by the server, so presence is every open tab with user = the player's public id, and an entry disappears on its own as soon as the connection closes.

Once a second, the ticker (WalkService::sync()) asks for presence over the Centrifugo gRPC API (CentrifugoPresence), reduces the entries to a set of players and, under the walkers lock, compares it with the hellowin:walkers key: present players get their seenAt updated, players with a look but no character step onto the lawn (the walker event), and characters whose players have been gone longer than HELLOWIN_WALK_LEAVE_AFTER ms leave (leave). Several tabs mean several entries for one user, but there is one character, and it leaves when the last tab closes. The leave delay keeps a page reload from dropping the character or moving it to the edge.

tab 1 tab 2 channel presence character on lawn ticker, every second reload walker leave HELLOWIN_WALK_LEAVE_AFTER with no connection at all gap shorter than the delay: the character stays put two tabs: two presence entries for one user, but one character; it leaves when the last tab closes
Rectangles are open connections and presence entries, the orange line is the character on the lawn, the marks are ticks. Lengths are illustrative: the delay is HELLOWIN_WALK_LEAVE_AFTER.

Why not the connect proxy: at connect time the client isn't subscribed to the channel yet and would miss its own walker event. Why not the refresh proxy with connection renewal: a departure would show up only when the term expired, and every renewal is an extra call and a KV write. Presence is a ready-made record of connections that Centrifugo cleans up itself on disconnect. A failed presence poll is logged, and the characters stay: an unavailable presence doesn't mean everyone left.

The server plans the character's path

A click on the ground is only an intent: the RPC move {x, y}. Nobody sends coordinates frame by frame. WalkService::move() clamps the target into the zone, the lawn rectangle walk.area minus the patch ellipse walk.patch from app/config/hellowin.php (a click inside the patch goes to its edge), starts from where the character is right now according to its current path and the server clock, and builds a polyline: straight if the segment misses the patch, otherwise along a tangent to the ellipse enlarged by 3 %, along its arc and along a tangent to the target, on whichever side is shorter. The duration is the polyline length divided by HELLOWIN_WALK_SPEED. Under the walkers lock the path is written to hellowin:walkers, and the channel gets a move event with Walk {path, startedAt, arrivesAt}.

walk.area: the lawn patch walk.patch characters stay out direct: crosses the patch from: where it is now by Walk and server clock target move {x, y} detour along the arc: shorter side, arc step ≤ 15° dashed: ellipse + 3 %: arc chords miss the patch click in patch → edge, on a ray from center Walk {path: [from, …, target], startedAt, arrivesAt} · time = length / HELLOWIN_WALK_SPEED
The orange polyline is the path from the move event; every client animates it the same way.

The server sets the animation, not the clicking player's client. From now in the snapshot, each browser knows its clock offset from the server's and places the character at the point on the path where it should be at that moment of server time. So everyone sees the same movement, and a tab opened mid-walk immediately draws the character halfway along. A new move during a walk starts from the current point.

Clinking mugs on click in the cursed world is the same path plus a deferred check: the RPC clink walks the character to the target and puts next to it in hellowin:walkers a record “waiting to meet by dueAt”, and every 200 ms the ticker, under the walkers lock, takes the records that are due and, if the target hasn't left, publishes exactly one clink event with a toast and the meeting time.

Flyers and zombies: points go to the first

The ticker launches flyers: at random once per HELLOWIN_FLYER_INTERVAL ms, the kind taken from the current scene's set HELLOWIN_FLYER_KINDS, occasionally a rare one (HELLOWIN_FLYER_RARE); the moderator can also release a special one by button or a whole wave (“Launch flyers”, “Limits”). The server sends the trajectory ready-made: a flyer event with points, depth, score and the launchedAt / expiresAt times, and in KV a hellowin:flyer:{id} key with a TTL plus an index of flyers in the air. Zombies in the cursed world are flyers too (kind: zombie, with their own interval HELLOWIN_ZOMBIE_INTERVAL), they just walk across the lawn from the edge of the patch outward, so the shooting, points and metrics are shared. A progress pause doesn't stop the ticker: flyers and zombies keep moving and a hit is credited to the player, only a zombie downed during the pause doesn't count toward the cursed world's goal.

A click on a flyer is the RPC shoot {flyer}. Under the flyer:{id} lock: if the key still exists, the flyer can be shot and its flight isn't over, the key is deleted and it's a hit: points to the player, one shot event, a metric. The next shot won't find the key: a miss. On every tick the ticker accounts for escaped flyers the same way: if the key still exists, delete it and count it as escaped. Exactly one of them deletes the key, so a flyer is never both shot and escaped. The server accepts a shot until expiresAt + 500 ms, and the client doesn't draw a flyer whose time has run out by the server clock.

Points add up to the evening's top shooters: hellowin:top under the top lock (TopService). A hit doesn't write “+N” there but the player's absolute score returned by their record, taking max(old, new): a retry or a race can't count twice. The shot itself publishes nothing: the ticker sends the board as a top {board} event when it has changed, and no more often than HELLOWIN_TOP_INTERVAL ms. This is coalescing: with a full room, the full list to everyone on every hit is too much, while once per interval it is one. The board lives all evening, a reset doesn't clear it, and it is also in the page snapshot.

lock flyer:{id} lock flyer:{id}: waits get: key exists, still flying delete: a hit release publish shot {points, player} · points to player · flyers_shot +1 lock acquired get no key miss: {ok: true, hit: false} a flyer is shot exactly once: the first under the lock deletes the key; the ticker counts escapes the same way RPC shoot player 1, first RR lock flyer:{id} RR kv hellowin:flyer:{id} RPC shoot player 2, next Centrifugo channel hellowin
Orange: the worker holds the lock; dashed frame: it waits for it.

The reveal: the evening's numbers

At the finale step where the owl reveals the truth, the finale event and the snapshot carry reveal, the round's real numbers. There are no separate per-click counters for this: everything follows from the state in Valkey, which is written under locks anyway. RevealBuilder recomputes them on every build.

NumberSourceWhy this way
clicks: processed clicksΣ accepted over hellowin:pumpkin:{id}accepted grows under the same lock as clicks but isn't zeroed on a transition
jobs: jobs that went through the queueΣ stage + sceneevery stage and every scene is exactly one executed transition job
burned: burned clicksclicks − Σ spent − Σ min(pumpkin's clicks, threshold)a transition spends exactly the threshold in force when it ran and adds it to the pumpkin's spent; the rest was dropped by idempotency or came after the pumpkin was ready, except clicks still piling up toward a transition
shot: downed flyers and zombieshellowin:shot: {round, count} under the shot lockthe only separate counter: shots are rare, the lock doesn't get in the way; another round's count reads as 0
drinks, slain, damage: the evening's mugs, zombies and strikes on FrankenPHPhellowin:drinks, hellowin:cursed, hellowin:boss, each with roundthe bar, cursed world and boss counters already exist for the game; another round's record reads as empty
lastPumpkin: who finished the last pumpkinlastPumpkin in hellowin:storythe player from the job that marked the scene ready; null if the click was anonymous or the scene was switched with force

A round reset zeroes these numbers without dedicated code: accepted, stage, scene and the story are reset along with the ritual, and the shot count is tied to the round.

Metrics

When a worker starts, HellowinBootloader declares the collectors in the metrics plugin and sets the gauges from the state in Valkey, which survives a RoadRunner restart. After that, RitualMetrics calls metrics.Add and metrics.Set over RPC from the click, job push, transition and reset, and PlayMetrics from the shot and the ticker (flyers, characters, players). A metrics error is only logged: a click never fails because of it. RoadRunner serves everything on :2112/metrics along with its own rr_http_* (the http_metrics middleware in .rr.yaml; also there is gzip, which compresses both PHP responses and static files; it sits before static because RoadRunner 3 runs middleware left to right).

Every 5 seconds Prometheus scrapes app:2112 and centrifugo:9000/metrics (Centrifugo's internal port: only the WebSocket on 8000 is exposed). Grafana gets the datasource and the “Hellowin” dashboard through provisioning and lets visitors in anonymously as Viewer: anyone can look, nobody can edit.

RitualMetrics ClickHandler: clicks TransitionQueue: pushed TransitionHandler: transitions + gauges boot, ResetService: gauges from Valkey RR metrics :2112 /metrics + rr_http_* Prometheus :9090 keeps 15d Grafana :3000 “Hellowin” board Centrifugo :9000/metrics Browser :3000 Add, Set :6001 scrape 5s PromQL scrape 5s anonymous Viewer pull model: Prometheus comes for the metrics itself, the app pushes them nowhere
Arrows show who initiates the request; on the scrape arrows the data flows back.
MetricSourceMeaning
hellowin_clicks_total{type, element}ClickHandlercounter: accepted clicks
hellowin_transition_jobs_pushed_total{kind}TransitionQueuecounter: transition jobs pushed to the queue, duplicates included; kind is pumpkin or scene
hellowin_transitions_total{kind}TransitionHandlercounter: transitions actually executed
hellowin_scene_stageTransitionHandler, startup, resetgauge: current scene
hellowin_roundstartup, resetgauge: current round
hellowin_pumpkin_stage{element}TransitionHandler, startup, resetgauge: stage of each pumpkin
hellowin_flyers_launched_total{depth}, hellowin_flyers_shot_total{depth}, hellowin_flyers_escaped_total{depth}ticker, RPC shootcounter: flyers (and zombies) launched, shot down, and escaped unshot
hellowin_walkersticker, every secondgauge: characters on the lawn
hellowin_players, hellowin_players_registeredticker, player count PlayerCensusgauge: known players and players with a name
hellowin_online_players, hellowin_online_connectionsWalkService ticker, Centrifugo channel presencegauge: who is online now, unique players and all connections including anonymous ones; the moderator sees the same number on the control panel
hellowin_rpc_calls_total{method, result}RPCServicecounter: every RPC over the WebSocket; method is the method name or unknown, result is ok or an error code
hellowin_rpc_duration_seconds{method}RPCServicehistogram: how long PHP took to handle the RPC
hellowin_rpc_rate_limited_total{group}RPCServicecounter: calls rejected by the limit (rate limited), by bucket group
hellowin_beer_mugs_total{place}BarServicecounter: mugs drunk; place is bar or cursed (the cursed world)
hellowin_beer_litresticker, player count PlayerCensusgauge: litres drunk over the evening, players' mugs × HELLOWIN_MUG_LITRES
rr_http_request_total, rr_http_request_duration_seconds, rr_http_workers_*RoadRunner httpHTTP requests, latency, workers
centrifugo_node_num_clients, centrifugo_transport_messages_sent, centrifugo_proxy_duration_seconds_histogramCentrifugoconnections, messages sent, latency of proxy calls to RoadRunner

Counters live in the RoadRunner process and reset when it restarts; gauges are restored from Valkey. Dashboard sections: Game: clicks, scene, round, online; Transitions: queue and idempotency: jobs versus executed transitions, dropped duplicates, pumpkin stages; Centrifugo: connections, messages, proxy calls; RoadRunner: HTTP and workers.

Queries from the dashboard, for example:

# clicks per second, total and per pumpkin
sum(rate(hellowin_clicks_total[1m]))
sum by (element) (rate(hellowin_clicks_total[1m]))

# dropped duplicates: jobs in the queue minus executed transitions
sum by (kind) (hellowin_transition_jobs_pushed_total) - sum by (kind) (hellowin_transitions_total)

# p95 latency of Centrifugo → RoadRunner proxy calls
histogram_quantile(0.95, sum by (le, type) (rate(centrifugo_proxy_duration_seconds_histogram_bucket[1m])))

Open the dashboard in Grafana · Back to the pumpkins