For Rust developers who have built a plaza controller and now need things to happen later: timeouts, effects that wear off and countdowns
You will build three small programs on plaza's optional scheduler modules. A typing indicator clears itself after three seconds of silence and every keystroke pushes that back. A slow effect wears off after three ticks and a second cast extends it instead of being cut short by the first. A lobby counts down to the start of a match and drops the countdown when a player backs out. None of them uses a wall-clock timer. Time only moves when the controller handles a time step, so every program runs the same way every time.
Before you start
You need Rust with Cargo. plaza comes from crates.io:
The schedulers are part of the plaza crate and need no feature flag. The programs use Tokio's single-threaded current_thread runtime. They also send each client op straight to the controller with ControllerCommand::SubmitAgentOps instead of through the session. That puts ops and time steps on the same queue, so each op lands at a known point in game time and the output is the same on every run. A real client sends its ops through the session and the rules handle them identically.
The controller does not know what time it is. A TickDriver sends it ProcessTimeStep commands and the rules receive each one as LogicInput::TimeStep { delta_time }. The rules add each step to a clock they keep in the state. That clock is the only time the schedulers ever see.
TimeEventScheduler<E> is a scheduler on that game-time axis. schedule_after(now, delay, event) stores an event of your own type and returns a ScheduledEventId. tick(now) hands back every event that has come due. The scheduler lives in the state, so it is owned by the controller like everything else and needs no lock.
For a typing indicator, each keystroke schedules a StopTyping event three seconds out. If the user was already typing, the rules cancel the previous event by its id, so only the latest keystroke's timeout is ever pending.
Replace src/main.rs with this:
src/main.rs
usestd::collections::HashMap;usestd::sync::Arc;usestd::time::Duration;useasync_trait::async_trait;useplaza::{agent::Agent,common::scheduler::{ScheduledEventId, TimeEventScheduler},controller::{CommandSender, ControllerCommand, StateControllerBuilder, query_with},session::{InProcessSession, TargetedOp,in_process::ClientInbox},state_logic::{LogicInput, LogicOutput, StateLogic, StateLogicError},tick_driver::TickDriver,};typeUserId=u32;constTYPING_TIMEOUT: Duration =Duration::from_secs(3);#[derive(Clone, Debug)]enumRoomEvent{ StopTyping(UserId),}#[derive(Debug, Default)]structRoom{now: Duration,typing:HashMap<UserId, ScheduledEventId>,timers:TimeEventScheduler<RoomEvent>,}#[derive(Clone, Debug)]enumChatOp{ Keystroke, Typing { user: UserId, typing:bool},}structRoomRules;#[async_trait]implStateLogic<ChatOp, UserId, Room>forRoomRules{ async fnprocess_input(&self,state:&mut Room,input:LogicInput<ChatOp, UserId>,)->Result<LogicOutput<ChatOp, UserId>, StateLogicError>{letmut out =Vec::new();match input {LogicInput::AgentOps { source, ops }=>{letSome(user)= source.id_cloned()else{returnOk(LogicOutput::none());};for op in ops {letChatOp::Keystroke = op else{continue};let timer = state.timers.schedule_after( state.now,TYPING_TIMEOUT,RoomEvent::StopTyping(user),);match state.typing.insert(user, timer){Some(previous)=>{ state.timers.cancel(previous);}None=> out.push(TargetedOp::new_system_all(vec![ChatOp::Typing { user, typing:true,}])),}}}LogicInput::TimeStep { delta_time }=>{ state.now += delta_time;for event in state.timers.tick(state.now){letRoomEvent::StopTyping(user)= event; state.typing.remove(&user); out.push(TargetedOp::new_system_all(vec![ChatOp::Typing { user, typing:false,}]));}}_=>{}}Ok(out.into())}}typeTx=CommandSender<ChatOp, UserId, Room>;async fnkeystroke(tx:&Tx, user:&Agent<UserId>)->Result<(), Box<dyn std::error::Error>>{ tx.send(ControllerCommand::SubmitAgentOps { agent: user.clone(), ops:vec![ChatOp::Keystroke],}).await?;Ok(())}async fnlook(tx:&Tx,inbox:&mutClientInbox<ChatOp, UserId>,)->Result<(), Box<dyn std::error::Error>>{let now =query_with(tx,|room|room.now).await?;letmut seen =false;whileletOk(msg)= inbox.try_recv(){for op in msg.ops {ifletChatOp::Typing { user, typing }= op {let doing =if typing {"is typing"}else{"stopped typing"};println!("t={}s bob sees user {user}{doing}", now.as_secs()); seen =true;}}}if!seen {println!("t={}s nothing new", now.as_secs());}Ok(())}#[tokio::main(flavor ="current_thread")]async fnmain()->Result<(), Box<dyn std::error::Error>>{let session =InProcessSession::<ChatOp, UserId>::new();let(tx, controller)=StateControllerBuilder::without_snapshots(Arc::new(RoomRules), session.clone(),Room::default(),).build();tokio::spawn(controller.run());let alice =Agent::new_human(1);let(_,mut bob_inbox)= session.connect(Agent::new_human(2)).await?;let second =Duration::from_secs(1);keystroke(&tx,&alice).await?;look(&tx,&mut bob_inbox).await?;TickDriver::run_virtual(&tx, second,2).await;keystroke(&tx,&alice).await?;look(&tx,&mut bob_inbox).await?;TickDriver::run_virtual(&tx, second,2).await;look(&tx,&mut bob_inbox).await?;TickDriver::run_virtual(&tx, second,1).await;look(&tx,&mut bob_inbox).await?;Ok(())}
The builder uses without_snapshots because a client joining this room needs no catch-up. TickDriver::run_virtual(&tx, step, n) sends n time steps of step each without waiting for real time to pass, which is how a test or a demo skips ahead. On a server you would spawn TickDriver::from_hz(10).run(tx.clone()) instead and let real time drive it.
look asks the controller for the time with query_with and then prints whatever Bob's inbox holds. The query travels the same command queue as the ops and the time steps, so by the time it answers, everything sent before it has been handled and its broadcasts are already in the inbox. try_recv then drains the inbox without waiting.
Run it:
$cargo runt=0s bob sees user 1 is typingt=2s nothing newt=4s nothing newt=5s bob sees user 1 stopped typing
Alice's first keystroke at 0s would have expired at 3s. Her second keystroke at 2s cancelled it and scheduled a new one for 5s, so Bob sees nothing at 4s and the indicator clears at 5s.
The same pattern undoes an effect. A Slow op marks the target as slowed and schedules a SlowEnds event. When the event fires, the rules clear the flag and tell everyone.
This program counts in ticks instead of seconds. TickEventScheduler<E> takes a u64 instead of a Duration and the rules add one to tick on every time step, whatever its length. Pick ticks when the game thinks in turns or frames and game time when it thinks in seconds.
look prints what Alice's inbox received and whether the controller says she is slowed.
Run it:
$cargo runtick 0 alice sees player 1 slowed slowed: truetick 1 alice sees nothing new slowed: truetick 2 alice sees nothing new slowed: truetick 3 alice sees player 1 recovered slowed: falsetick 4 alice sees nothing new slowed: false
Bob slows Alice at tick 0 and the effect ends at tick 3, three ticks later.
Now Bob casts the slow a second time at tick 2, while Alice is still slowed. The second cast should run for three ticks from tick 2 and end at tick 5. Replace main with this:
src/main.rs
#[tokio::main(flavor ="current_thread")]async fnmain()->Result<(), Box<dyn std::error::Error>>{let session =InProcessSession::<ArenaOp, PlayerId>::new();let(tx, controller)=StateControllerBuilder::without_snapshots(Arc::new(ArenaRules), session.clone(),Arena::default(),).build();tokio::spawn(controller.run());let(_,mut alice_inbox)= session.connect(Agent::new_human(ALICE)).await?;let bob =Agent::new_human(BOB);let tick =Duration::from_millis(100);slow(&tx,&bob,ALICE).await?;look(&tx,&mut alice_inbox).await?;for n in1..=6{TickDriver::run_virtual(&tx, tick,1).await;if n ==2{slow(&tx,&bob,ALICE).await?;}look(&tx,&mut alice_inbox).await?;}Ok(())}
Run it:
$cargo runtick 0 alice sees player 1 slowed slowed: truetick 1 alice sees nothing new slowed: truetick 2 alice sees player 1 slowed slowed: truetick 3 alice sees player 1 recovered slowed: falsetick 4 alice sees nothing new slowed: falsetick 5 alice sees player 1 recovered slowed: falsetick 6 alice sees nothing new slowed: false
The first cast's SlowEnds still fired at tick 3 and ended the second cast two ticks early. By the time scheduled work fires, the game may have moved on, so the work has to check whether the moment it was scheduled for still stands.
The typing indicator handled this by cancelling the old event. The other way is a Situation, a counter for one recurring decision, here how long Alice stays slowed. Call advance whenever that decision changes, stamp the scheduled event with mark() and check holds(mark) when it fires. A mark taken before the last advance no longer holds.
Add the import game_common::flow_control::{Mark, Situation} to the plaza list. Each player gets their own Situation and the event carries a Mark:
A cast advances the target's situation before scheduling:
src/main.rs
let player = state.players.entry(target).or_default();player.slowed =true;player.slow.advance();let mark = player.slow.mark();state.timers.schedule_after( state.tick,SLOW_TICKS,ArenaEvent::SlowEnds { target, mark },);out.push(to_all(ArenaOp::Slowed { target }));
When the event fires, it does nothing unless its mark still holds:
src/main.rs
for event in state.timers.tick(state.tick){letArenaEvent::SlowEnds { target, mark }= event;letSome(player)= state.players.get_mut(&target)else{continue;};if!player.slow.holds(mark){continue;} player.slowed =false; out.push(to_all(ArenaOp::Recovered { target }));}
Run it:
$cargo runtick 0 alice sees player 1 slowed slowed: truetick 1 alice sees nothing new slowed: truetick 2 alice sees player 1 slowed slowed: truetick 3 alice sees nothing new slowed: truetick 4 alice sees nothing new slowed: truetick 5 alice sees player 1 recovered slowed: falsetick 6 alice sees nothing new slowed: false
The tick 3 event carried the first cast's mark, which stopped holding when the second cast advanced the situation, so it was skipped. Alice stays slowed until tick 5. Mark is Copy and holds one u64, so an event carries it by value.
A state with more than one kind of decision can give each Situation a marker type, as in Situation<Ask> and Situation<March>. Checking a Mark<Ask> against a Situation<March> is then a compile error. A state with one kind uses the default and writes plain Situation and Mark.
Some work belongs to a phase of the game rather than to one decision. A lobby that counts down to the start of a match is one. If a player backs out during the countdown, the lobby goes back to waiting and the pending start has to be dropped. If everyone readies up again, a new countdown starts from the beginning.
Phased<P> holds the current phase. The only way to change it is transition_to, which also queues a PhaseChangedNoticePayload for every client, so a client can never miss a phase change. Each change also starts a new occupancy of the phase, identified by an Epoch. PhasedScheduler<E> is a tick scheduler that records the epoch when you schedule and whose due(now, &phase) returns only events whose occupancy is still current. It drops the rest.
transition_to takes a context to push its notice into and a function that wraps the notice in your op type. OpsQueue is the minimal context. ctx.into_ops() turns what it collected into the rules' output.
Phased::new announces nothing. The opening phase is part of the state, not a change. The start is scheduled after the transition to Countdown, so it belongs to that occupancy. Scheduling it before the transition would stamp it with the occupancy that just ended.
Run it:
$cargo runtick 0 alice sees stage Countdowntick 1 alice sees stage Waitingtick 2 alice sees stage Countdowntick 3 alice sees nothing newtick 4 alice sees nothing newtick 5 alice sees stage Playing
Both players ready up at tick 0 and the start is due at tick 3. Bob backs out at tick 1 and readies again at tick 2, so the second countdown's start is due at tick 5. At tick 3 the first start came due, but its occupancy of Countdown had ended, so due dropped it and the match started at tick 5 as it should. Returning to Countdown was a new occupancy even though the phase has the same value.
PhasedScheduler checks only the epoch. When the phase stays the same and something smaller moves on, such as a turn passing, pair it with a Mark as in the previous section. PhasedScheduler has no cancel, so a Mark is the way to retire one of its events early.
There are two scheduler shapes and each runs on either axis. EventScheduler returns your events for the rules to match on, as all three programs here do. CallbackScheduler runs closures against the state instead and comes as TickCallbackScheduler and TimeCallbackScheduler. Repeating schedules (schedule_repeating_after) skip ahead after a stall rather than firing every missed interval. common::reconnect::ReconnectTracker uses the same no-timers approach for disconnect grace periods.