Don't Animate a Guess. Let the Rules Engine Return the Replay.

My game engine knew that one piece had moved, another had been pushed, a tile had cracked, and perhaps somebody had left the board at speed.

The Flutter UI knew that the board looked different now.

That left a tempting little gap.

The UI could compare the old board with the new one, infer what probably happened, and animate the difference.

Probably.

“Probably” is a terrible animation protocol.

If the engine has already resolved a move, asking the UI to rediscover that resolution creates a second rules engine.

It is a smaller rules engine, written in a different language, hidden inside presentation code, and tested mainly by the reassuring sight of pieces moving in approximately the right direction.

This is how a harmless transition becomes a constitutional crisis.

I took a stricter route in Ttush Push.

The Rust engine returns two values for every applied move:

  • the canonical next snapshot;
  • the exact resolution needed to replay the move.

Flutter stages both, animates the resolution against the still-visible old board, and commits the already-validated snapshot only when the replay finishes.

The animation is not an interpretation of the rules.

It is a visualization of the engine's answer.

A next state is not a replay

Suppose a move changes this:

before: piece 4 at (2, 2), piece 7 at (2, 3), normal tile at (2, 2)
after:  piece 4 at (2, 3), piece 7 at (2, 4), damaged tile at (2, 2)

A diff can discover several facts.

Piece 4 moved.

Piece 7 moved too.

The departure tile changed.

But the diff does not carry the reason those facts belong together.

Was this a push?

Was piece 7 displaced by piece 4, or did two independent changes happen during a larger transition?

If piece 7 disappeared, which edge did it leave through?

Which movement should lead the animation, and which should follow?

The final state deliberately discards that history.

That is its job.

A snapshot answers, “What is true now?”

A replay answers, “What happened?”

Trying to derive the second answer from the first is possible only while the rules remain pleasantly simple.

Games are not famous for remaining pleasantly simple.

Make the engine return what the UI is allowed to know

The Rust boundary returns a MoveResult:

pub struct MoveResult {
    pub snapshot: MatchSnapshot,
    pub resolution: MoveResolution,
}

The resolution is a value object, not an animation command.

It contains the rule-level facts Flutter needs:

pub struct MoveResolution {
    pub action_kind: MoveActionKind,
    pub mover: PieceTravel,
    pub displaced: Option<PieceDisplacement>,
    pub tile_transition: TileTransition,
}

PieceTravel identifies the moving piece and its origin and destination.

An optional PieceDisplacement does the same for a pushed piece and can include an exit direction when that piece leaves the board.

TileTransition records the affected coordinate and the tile state before and after the move.

Notice what is not in this object.

There is no duration.

There is no easing curve.

There is no Flutter widget key.

Rust owns the rules, but it does not direct the stage lighting.

Flutter still decides how a push should feel, how reduced motion changes the replay, and when to announce the result.

The boundary carries facts, not presentation policy.

That distinction keeps both sides honest.

Return the snapshot and the resolution in one engine call

The application path reconstructs and validates the submitted snapshot, applies the requested move with resolution data, and returns both outputs together.

Conceptually, it is this:

let state = match_state_from_snapshot(snapshot)?;
let (next, resolution) = state.apply_move_with_resolution(game_move)?;

Ok(MoveResult {
    snapshot: match_snapshot_from_state(&next),
    resolution: move_resolution_from_engine(&resolution),
})

The real conversion also includes the departure tile and direction needed to build the bridge value.

The important part is the transaction boundary.

There is one accepted input snapshot, one applied move, one canonical next snapshot, and one matching resolution.

Flutter never calls “apply” and then makes a second request for “what happened?”

That second request would need an event store, a move identifier, or some other way to prove it describes the same transition.

Without that, the UI could receive a snapshot from one move and replay data from another.

Returning them together makes the pairing boring.

Boring is excellent protocol design.

Do not publish the next board before replaying it

Getting an authoritative result across the bridge solves only half the problem.

The UI still needs to show the old board while the movement runs.

If the controller replaces its visible snapshot immediately, the animation starts from the destination.

The piece has already arrived, and the UI must invent a ghost of the previous state to make it travel again.

That is another form of reconstruction.

Instead, prepareHumanMove captures the currently visible snapshot and asks the engine to apply one legal move.

The controller validates the returned snapshot and calculates its legal moves before publishing anything.

Then it stores both in a pending value:

final result = produce();
_validateContract(result.snapshot);
final legalMoves = _engine.legalMoves(result.snapshot);
_pendingMove = _PendingMove(result, legalMoves);

The visible _snapshot remains unchanged.

So does the visible legal-move cache.

The page reads pendingResolution, replays it on top of the old board, and waits for the animation controller to report completion.

Only then does it call commitPendingMove.

The commit is intentionally dull:

_snapshot = pendingMove.result.snapshot;
_legalMoves = pendingMove.legalMoves;
_pendingMove = null;

The board and its legal moves become visible together.

There is no frame where the new board is paired with the previous board's controls.

There is also no need to apply the move again after the animation.

The engine result was already final.

The UI merely delayed publication.

The pending state is a small transaction

Calling this a transaction may sound grand for a board-game animation.

It is still the useful mental model.

The controller has three phases:

visible old state
    -> validated pending result
    -> visible new state

During the middle phase, controls are blocked.

A second tap cannot prepare another human move.

A bot cannot sneak in a turn.

The page also guards completion with three checks: the widget must still be mounted, the replay generation must still match, and the controller must still hold the same resolution object.

Those checks matter because an animation callback is delayed code.

By the time it runs, the page may have been disposed, another replay may have superseded it, or the pending state may have been cleared.

An animation finishing is not permission to commit whatever happens to be pending now.

It is permission to commit the exact transition that animation was replaying.

Read the next legal moves before publishing the next snapshot

There is a less cinematic detail hiding in _prepareMove.

The controller asks the engine for the next state's legal moves before it sets _pendingMove.

Why not commit the snapshot first and refresh the controls afterward?

Because the bridge can fail between those two operations.

If that happens, the player sees the new board with legal moves from the old board.

The picture and the available actions disagree.

That is worse than an error message because it looks interactive.

The preparation phase therefore gathers the whole publishable bundle first:

  • canonical next snapshot;
  • exact move resolution;
  • legal moves for that next snapshot.

If applying the move fails, the old board remains visible.

If reading the next legal moves fails, the old board still remains visible.

The retry action prepares the move again from that unchanged state.

The controller does not display half a successful transition.

A resolution is evidence, not authority shared with the UI

It would be easy to misread this design as “Rust and Flutter both know the move.”

They do not know it in the same way.

Rust decides whether the move is legal and computes its consequences.

Flutter receives a report of those consequences.

The report does not let Flutter invent a different result.

The Dart RulesEngine boundary accepts snapshots produced by Rust, and the Rust side reconstructs state from the submitted snapshot before applying a move.

Every returned match snapshot includes its canonical hash.

The UI also performs shape checks so a bridge or schema fault is treated as an error instead of a board state.

Those checks do not reimplement the game rules.

They defend the boundary.

The difference is important.

“The winner needs enough round wins” is a cross-boundary consistency check.

“This push is legal because the destination tile has these properties” belongs in the engine.

Once presentation code starts explaining why a move is valid, the second rules engine has returned wearing a fake moustache.

Test the seam, not just the final picture

The controller tests use a fake RulesEngine to make the staging behavior observable.

They verify that preparing a human move exposes a pending resolution without replacing the visible snapshot.

They verify the same property for a bot move.

They reject controls while a move is pending.

They make move application fail and confirm that the visible match survives for retry.

They also make the next legal-move lookup fail after the engine has returned a result.

The snapshot still does not advance.

That last case is the one a happy-path animation test is least likely to find.

The Rust bridge tests cover the other side of the contract with focused examples for a normal move, a push, and a knockout exit direction.

At the source revision used for this article, the Dart controller suite passed 32 tests and the focused Rust bridge filter passed three tests.

Those are host-side checks.

They do not prove that the generated native bridge currently launches on Android or iOS, and I did not run a device parity pass for this article.

The evidence is useful because its boundary is explicit.

The UI should choreograph facts, not infer them

There is a healthy division of labor here.

The engine returns:

  • what the next state is;
  • which piece travelled where;
  • whether another piece was displaced;
  • where that piece went or exited;
  • which tile changed;
  • which kind of action occurred.

The UI decides:

  • how long the replay takes;
  • which easing to use;
  • how push feedback feels;
  • how reduced motion changes the presentation;
  • what accessibility announcement follows the commit.

This is not about moving all behavior into Rust.

It is about keeping every decision on the side that owns it.

The engine owns game truth.

The UI owns how that truth becomes visible.

The pending transaction is the handshake between them.

The practical rule

When a state transition needs animation, do not ask only for the next state.

Ask the authoritative system for the replay facts at the same time.

Stage the result without publishing it.

Gather any dependent state needed for the next interactive frame.

Replay the facts against the old visible state.

Then commit the whole bundle once, after confirming that the completion callback still belongs to that transition.

The pattern is larger than games.

It fits drag-and-drop boards, optimistic editors, workflow visualizations, financial ledgers, and any interface where “what changed” carries meaning that the final record no longer contains.

A diff can tell you that the world is different.

It cannot always tell you the story the authoritative system just decided.

If the engine already knows that story, let it return the replay.