Godot + Fortress Rollback¶
Use SignalFishPollingClient<GodotWebSocketTransport> as the relay beneath a
Fortress Rollback NonBlockingSocket. This is the same architecture exercised
by the repository's two-process Godot browser test.
Add the rollback library beside the SDK in the Godot GDExtension crate:
fortress-rollback = "=0.10.0"
godot = { version = "0.5.4", features = ["api-custom", "experimental-wasm", "experimental-wasm-nothreads", "lazy-function-tables"] }
serde = { version = "1.0", features = ["derive"] }
signal-fish-client = { git = "https://github.com/Ambiguous-Interactive/signal-fish-client-rust", default-features = false, features = ["polling-client"] }
signal-fish-client-godot = { git = "https://github.com/Ambiguous-Interactive/signal-fish-client-rust" }
The adapter supports godot-rust 0.4.5 through 0.5.x and requires Rust 1.94;
the framed-transport-agnostic core remains compatible with Rust 1.87. Keep the direct
godot dependency aligned with the binding used by the adapter. Run
cargo tree -d after dependency updates and resolve any duplicate godot or
godot-* families before passing Gd values across the adapter boundary,
because bindings from different versions are distinct Rust types.
The issue #61 polling and admission guarantees in this guide are currently on
main under Unreleased;
use the git dependency until the next crate release publishes them.
Configure Signal Fish¶
Enable protocol v3 and MessagePack before constructing the polling client:
use signal_fish_client::protocol::GameDataEncoding;
use signal_fish_client::{SignalFishConfig, SignalFishPollingClient};
use signal_fish_client_godot::GodotWebSocketTransport;
let transport = GodotWebSocketTransport::connect("wss://example.com/v2/ws")?;
let mut config = SignalFishConfig::new("mb_app_example").enable_v3();
config.game_data_format = Some(GameDataEncoding::MessagePack);
let mut client = SignalFishPollingClient::new(transport, config);
Create or join the room only after Authenticated. Build the Fortress session
after RoomJoined and the roster contains every expected player. Sort Signal
Fish player UUIDs and use their positions as stable Fortress player handles so
every process derives the same mapping. The included fixture intentionally
requires exactly two players. For a larger game, sort the complete UUID roster
on every client and assign every handle from that shared order.
Relay framing¶
Fortress NonBlockingSocket::send_to is synchronous and best-effort. Give the
socket bounded inbound and outbound queues shared with the game-loop adapter.
For each outbound Fortress Message:
- Encode it with
fortress_rollback::network::codec::encode. - Prefix the bytes with the destination player's 16-byte UUID.
- Queue the frame without blocking.
- Pump it with
send_binary_game_data. Pop the front frame only on success; retain it for ordered retry onSignalFishError::SendBufferFull, and treat every other error as fatal without silently dropping the frame.
Signal Fish broadcasts game data to the room. On receipt, require a MessagePack
v3 envelope with nonzero sequence and epoch and verify the sender is in the
roster. Ignore a valid frame whose UUID prefix names another room member;
require locally addressed frames to name the local UUID, then decode the
remaining bytes with fortress_rollback::network::codec::decode_message.
Reject trailing bytes and keep both relay queues bounded.
The complete tested adapter is in
tests/godot-web-smoke/src/fortress.rs.
Frame order¶
Drive the integration once from Godot's _process callback:
- Call
client.poll()exactly once and route binary events into the relay. - Call
session.poll_remote_clients(). - If Fortress is running, add deterministic local input and advance one frame.
- Pump the relay's bounded outbound queue into the SDK command queue.
Messages produced in step 3 are offered to the WebSocket on the next rendered
callback. This ordering preserves the real frame-driven pressure that exposed
issue #61 while keeping each callback bounded. Inspect polling_stats() for
queue peaks and work-budget exhaustion, and queue_age_stats() so a
stable-depth but increasingly stale queue is visible; reset the age peak when
measured simulation begins. Inspect transport_diagnostics() separately for
admission hits, backend buffering, and accepted multi-frame bursts.
System test¶
The required Godot Web checks reuse one official Godot 4.5 no-thread export
across clean, impaired, and soak jobs. Every job launches two independent
Chromium processes and a real Signal Fish server. Clean, impaired, and soak
run against server 0.7.0; a second clean job pins server 0.4.0 as the explicit
legacy compatibility gate. The clean case advances 600 confirmed frames; the impaired case adds seeded bidirectional 40 ms delay,
10 ms jitter, 0.2% correlated loss, a 10 Mbit/s rate, and a six-callback polling
hitch at frame 240; the soak advances 3,600 confirmed frames under the same
profile. The fixture configures a 20-frame prediction window so acceptable
constrained-network lag and the declared hitch can recover without an internal
scheduler stall. Simulated frames 1 through 60 are an explicit browser
renderer/JIT warm-up phase bounded by the 20-frame prediction window. From
frame 61 onward, the scenario oracles cap confirmation lag at eight clean or
13 impaired/soak frames; final current lag must obey the same scenario bound.
Simulation advances on a fixed
local 18 Hz cadence, independent of peer or network progress, so unequal browser CPU
slices do not become artificial frame advantage and real prediction-window
stalls remain observable. Delayed callbacks retain their elapsed deadline debt
and recover by at most one simulation frame per rendered callback, preventing
permanent scheduling skew without allowing a multi-frame burst. A bounded
one-time proposal/ack/commit barrier maps a shared deadline to each browser's
monotonic clock, preventing process-start order from becoming measured gameplay
skew. This wall-clock assumption is fixture-scoped because CI launches both
Chromium processes on the same host. A bounded relay hold uses causal
post-advance frame watermarks to prove the remote peer
predicted the changed delayed input before release, forcing rollback, state
load, and resimulation while both games keep advancing. The hitch oracle
separately requires forward simulation progress during its six skipped polling
callbacks. CI builds
the pinned, checksum-verified iproute2 6.6.0 tc because the runner's packaged
version cannot apply a deterministic netem seed.
The gates require exact checksum convergence, in-sync health, bounded
phase-aware confirmation lag, zero stalls (advisory frame-advantage wait
recommendations are reported but not required to be zero), at least two relay
messages per simulated
frame, final queue depth and age of zero, a sampled queue-age peak no greater
than 500 ms, a non-positive final eight-sample queue-age slope for the soak,
exact client/server conservation, and an observable v3 PlayerLeft terminal
watermark. Browser/server logs, time series, summaries, Prometheus snapshots,
and netem seed/configuration are uploaded even on failure.