Simulators
Scripted events
A schedule lists events that fire when the simulator clock reaches them. So a scenario can carry an outside feed (a score, a weather reading) and an ending (a settlement) alongside the market. Each event has a time and one payload:
| Payload | When the event fires, it |
|---|---|
message | lands on GET /_feed |
seed | merges into state, as POST /_rystic/seed would |
call | calls one of the simulator’s own routes outside /_rystic/, such as settling a market when its game ends |
{"id":"end","at":"2026-08-22T01:50:07.240Z","call":{"method":"POST","path":"/_settle","body":{"ticker":"KXMLBGAME-26AUG211840STLPHI-PHI","result":"yes"}}}
A call on a Trade API route runs as the actor as names (member by default), through the same guards and handler as your own requests. Its WebSocket frames go out on schedule. as and headers belong to Trade API routes only. An event’s at is an RFC 3339 time, or +<duration> (+90s, +15m) from the clock when the schedule loads.
Load a schedule with POST /_rystic/inject, or at boot with --inject <file> or RYSTIC_INJECT=<file> (rystic run --inject <file> sets it). A file holds either the {"events":[…]} body the route takes or one event per line (JSONL). The empty_book scenario holds its clock still at 2026-07-25T14:30:00Z, so a schedule fires when you move the clock:
export RYSTIC_URL=$(rystic run kalshi-twin -d --port 0 --name events --scenario empty_book --json | jq -r .data.url)
curl -s -X POST "$RYSTIC_URL/_rystic/inject" -d '{"events":[{"id":"td","at":"2026-07-25T14:31:12Z","message":{"channel":"play_by_play","body":{"text":"TD KC, 7-3"}}},{"id":"wx","at":"2026-07-25T14:32:00Z","message":{"channel":"weather","body":{"hpa":1004.2}}}]}'
curl -s "$RYSTIC_URL/_feed?since=0"
# -> {"events":[]}
curl -s -X POST "$RYSTIC_URL/_rystic/clock" -d '{"time":"2026-07-25T14:31:30Z"}'
curl -s "$RYSTIC_URL/_feed?since=0"
# -> {"events":[{"seq":1,"time":"2026-07-25T14:31:12Z","channel":"play_by_play","body":{"text":"TD KC, 7-3"}}]}
curl -s -X POST "$RYSTIC_URL/_rystic/clock" -d '{"advance_seconds":60}'
curl -s "$RYSTIC_URL/_feed?since=1"
# -> {"events":[{"seq":2,"time":"2026-07-25T14:32:00Z","channel":"weather","body":{"hpa":1004.2}}]}
When events fire
- Each event fires once, when the clock is pinned at or past its
at. A replaying tape,POST /_rystic/clockor a scenario’sclockmoves the clock, and so does pacing. A jump fires every event it passes, in order. The one-millisecond tick per read doesn’t count. RYSTIC_PACE=<speed>moves the clock byspeedseconds per second.rystic run --injectsets1unless--speedsays otherwise, and--tapeand--livenever set it. Pacing waits while a/_replaytape or a live feed drives the clock.btc15m_tapedrives the clock itself, so don’t pace beside it: set--speed 0.- A replaying tape fires each event on its own step, after the tape events before its
atand before the rest, at any speed. Events past the tape’s last event never fire, unless a checkpoint past it carries the clock there. Then those due by the stop fire before the pause. Fractional timestamps keep nanosecond precision, and replacing the schedule during playback reschedules pending events. atis the clock’s time, so arebasereplay shifts the tape away from it. To writeatin tape time, load the file with the replay, as script events on tape time describes.POST /_rystic/resetre-arms the whole schedule and empties the feed.seqstarts again at1, and an offsetatreplays at the same instant.
Reading the feed and the schedule
GET /_feed?since=<seq> serves only fired messages, never seeds or what is still pending, and only the ones after seq. It sits outside /_rystic/ and the modeled rate limit, so a UI can poll it.
GET /_rystic/inject lists every event with these fields:
| Field | What it holds |
|---|---|
due | the instant the event is due |
fired_at | when it fired |
as | a Trade API call’s actor |
status | a call’s status |
error | why the store refused a seed, or the call’s 4xx or 5xx |
An error doesn’t stop the schedule: later events still fire. A call must name a route the simulator serves, and anything else refuses the whole schedule. A Trade API call’s entry in GET /_rystic/calls carries source: "timeline" and the event’s id as event.
A new POST /_rystic/inject replaces the schedule, and fires at once what the clock has already passed. An id is an event’s identity. An event the old schedule already fired stays fired in the new one, keeping its fired_at, its feed line and what it bound. So re-posting an overlapping rolling window never fires an event twice. To fire an edited event again, give it a new id.