Skip to content
game-server · rust · friendslop

Authoritative physics hostFriendslop Server

The dedicated authoritative host for Friendslop — the rapier3d sim from the shared q crate on a fixed-tick thread, behind an axum WebSocket transport. Clients send intent; the server decides what it means.

Intent, not motion

A client says "I want to go this way"; the host decides what that means against gravity, speed, and the world. The sim steps at 60 Hz and fans out 20 Hz snapshots.

  • Shared seed — terrain is baked, never transmitted.
  • Lifecycle — Agones Ready / Health / Shutdown.
friendslop-serverApp
axum + rapier3dStack
WS + UDPLanes
0.0.0.0:7980Listen

What it gives you

Features

Authoritative sim

Steps the rapier3d character controllers and rigid bodies at 60 Hz on a dedicated OS thread, so a busy socket can never stall the tick.

Intent-only inputs

Clients send a wish direction and a jump flag; magnitude is clamped host-side, so an oversized vector buys a cheater nothing.

Seed-baked terrain

Collision heights come from the shared q::worldgen generator, so client and server agree on ground without shipping a heightfield.

Agones lifecycle

Runs the Agones SDK lifecycle (Ready / Health / Shutdown); degrades gracefully outside Agones for local dev.

Responsibilities

Overview & layout

friendslop-server is the dedicated authoritative host for Friendslop. It runs the rapier3d sim from the shared q crate (packages/rust/q, built with --no-default-features --features net-dedicated) on a fixed-tick OS thread, behind an axum WebSocket transport.

The end goal for Friendslop is Steam P2P co-op, where one player hosts. That is not a different program: hosting is a role, not a build. HostSession drives the player-hosted listen server and this dedicated server identically — the only difference is whether the host occupies a player slot (HostSession::new vs HostSession::dedicated). Running dedicated first means the whole session layer can be exercised against the Kubernetes cluster without waiting on Steam appid approval.

  • Step the authoritative SimWorld at 60 Hz on its own thread, fanning out 20 Hz snapshots.
  • Accept WebSocket peers, assign each a PeerId and a display name, and despawn their character when the socket drops.
  • Apply client intent — a horizontal wish direction and a jump flag — against server-owned gravity and speed.
  • Own every deployed pet robot: placement, steering, the caps, and the flow field each owner’s group routes on.
  • Run the Agones SDK lifecycle (Ready / Health / Shutdown); degrades gracefully outside Agones for local dev.

A player arrives one of two ways, and the server decides what they are called either way.

SessionMsg::Join is a guest. Its name field is vestigial and ignored — a guest that could ask for a name could ask for someone else’s — so the host generates Anon-XXXX and hands it back in Welcome.

SessionMsg::JoinAuthed carries a Supabase access token and nothing else: the name is in the token. auth.rs verifies it locally against the JWKS at SUPABASE_URL/auth/v1/.well-known/jwks.json (ES256 today, HS256 still accepted if SUPABASE_JWT_SECRET is set) through the shared jedi::jwks::JwtVerifier, then takes kbve_username from the claims. Verification is local rather than a call to GoTrue per join — the sim thread cannot await, and a login endpoint that has to be up for players to walk around is a dependency this server does not need.

The name from a token is still sanitized (ASCII alphanumerics plus single interior _ - ., 24 chars, no all-digit names): a claim signed by an identity provider is still a string that lands on someone else’s screen. An account already in the session is refused rather than duplicated, and a token that does not verify is refused rather than quietly downgraded to a guest — signing in and silently arriving as somebody else is worse than not arriving.

A server with no SUPABASE_URL (or SUPABASE_JWKS_URI) runs guests-only and rejects JoinAuthed outright. It does not fail open: a host that cannot verify a token has no way to tell an account from a claim about one.

JoinAuthed was appended to the message enum rather than folded into Join, so postcard’s variant numbering did not move and the guest path speaks to older builds unchanged.

A player asks for a robot; the server decides whether they may have it, puts it down beside them, gives it a rapier character body, and steers it every tick. The client chooses the chassis and nothing else — where it lands, which id it gets and whether it is allowed at all stay here, because a client that could name any of those could deploy an army into somebody else’s world.

Two caps, answering different questions. Ten per player is the game rule. Ninety-six per world is the resource bound behind it: sixteen players at their personal limit is 160 extra bodies in every snapshot, to everyone, twenty times a second. One player at their limit must not be able to fill the world alone, so both are checked.

Replication needed nothing new — a pet is an ordinary body and already rides SimSnapshot. What was missing is identity, so SessionMsg::Pets carries the roster the way Roster carries players: which body is a pet, whose it is, which chassis to draw. Bodies sit in a reserved id band above the player range, which is how a client tells a robot from an avatar without waiting for that roster to arrive.

Pets do not outlive their owner. A leaderless pet has nobody to follow and nobody to recall it, so it would stand there for the rest of the session holding a slot against everyone else’s cap.

Routing — one flow field per owner, not per pet: everything somebody deployed wants the same place, so they share one integration, and ten pets cost the same as one. Each field is a 128-unit window around its owner at two-metre cells rather than a map-sized grid, because pets hold at six metres and sprint at ten. Costs go down in a fixed order — ground, then rocks by coverage, then the crossing closed as the solid causeway it is before the clearance is grown, then the walkway cut back out past both ends. Measured against the generator: 2.3 ms to stamp and integrate, 1.0 ms to integrate again after the owner walks. One field’s worth of that per tick is the ceiling however many players are on, and the round robin rotates so nobody starves.

/stats reports pets and pet_fields, which is the only outward sign the caps are holding.

apps/agones/friendslop/server/
├── Cargo.toml # path-dep to ../../../../packages/rust/q (net-dedicated)
├── Cargo.workspace.toml
├── Dockerfile # multi-stage chisel build (cargo-chef + sccache)
├── project.json # nx build / run / container / e2e targets
├── version.toml # ci-publish version sentinel
├── e2e/ # vitest smoke: healthz, tick advance, peer lifecycle
└── src/
├── main.rs # tokio entry, /stats route, graceful shutdown
├── driver.rs # fixed-tick sim thread + seed-baked terrain
├── auth.rs # Supabase JWKS verification
├── terrain_stream.rs # heightfield regions that follow players
├── props.rs # stone colliders for the loaded regions
└── agones.rs # Agones SDK heartbeat

Build, run & deploy

Sim core, container & deployment

Build — local build via nx:

Terminal window
nx run friendslop-server:build-release

The release binary lands at dist/target/release/friendslop-server per the workspace target dir convention.

Container

Terminal window
nx run friendslop-server:container
nx run friendslop-server:e2e

e2e builds the image, runs it, and drives it with vitest: /healthz, a /stats tick counter that must advance (proving the sim thread is live and not merely that axum answers), and WebSocket peer connect/release accounting.

Configuration — all via environment:

VariableDefaultMeaning
FS_SERVER_ADDR0.0.0.0:7980Listen address
FS_SERVER_SEED1337World seed; clients bake terrain from it
FS_TICK_HZ60Sim step rate
FS_TERRAIN_EXTENT256Half-extent of the collision heightfield
FS_TERRAIN_RESOLUTION513Heightfield grid resolution
FS_PETS_PER_PLAYER10Robots one player may have deployed
FS_PETS_TOTAL96Robots the whole session may hold

Sim core — the physics lives in q::rapier::sim3d, which is engine-agnostic: pure rapier3d, no Godot, no Bevy. The identical step code compiles into the Godot client’s QPhysics3D bridge and into this headless binary. Session logic (q::net::session) sits on top of the q::net::transport::Transport seam, so it is testable in-process over Loopback with no socket and no engine anywhere in the test.

Terrain is never transmitted. Both sides bake heights from the seed via q::worldgen, and fixed bodies are culled from snapshots entirely — a fixed body cannot move, so replicating it is pure bandwidth.

Transport caveat — WebSocket rides TCP, so Delivery::Unreliable is currently advisory: snapshots are still retransmitted and still head-of-line block. delivery is carried through the seam rather than discarded so a UDP side-lane can route on it, with the reliable channel keeping join/welcome traffic. Until that lands, treat WS as correct-but-not-final.

Deployment — an Agones Fleet (apps/kube/agones/friendslop/manifests/) with replicas: 0 and a buffer autoscaler. The image must land on ghcr.io/kbve/friendslop-server before the Fleet can scale up, and the ArgoCD application is not registered yet.

The two lanes are exposed differently because UDP cannot traverse an L7 HTTPRoute. The WebSocket goes through the Cilium gateway; the datagram lane gets its own L4 LoadBalancer Service (friendslop-game-udp). Both use portPolicy: None, so no hostPort is involved and the pods stay compatible with baseline PodSecurity — the same shape arpg already uses for its WS + UDP split.

The public IP pool is a single /32, so every LoadBalancer in the cluster shares 142.132.206.71 via sharing-key: public-71. Cilium only permits that when each participant names the others in sharing-cross-namespace, so registering this service also required adding friendslop to kbve-gateway and to arpg-game-udp. Port 7981/UDP is free on that IP.

Because the datagram lane answers on the LoadBalancer IP rather than the gateway hostname the WebSocket uses, the server advertises it explicitly via FS_UDP_ADVERTISE_HOST / FS_UDP_ADVERTISE_PORT. When Agones does port-map instead (portPolicy: Dynamic or Static), the server falls back to reading its published address and port from the Agones SDK.

Status — Beta, undeployed. Container and e2e green locally; Fleet manifests written but not registered with ArgoCD, and Godot scene wiring outstanding.

Questions

Frequently asked

What is the Friendslop server?

friendslop-server is the dedicated authoritative host for Friendslop. It runs the rapier3d physics sim from the shared q crate on a fixed-tick OS thread and fans out snapshots over an axum WebSocket transport, so movement, gravity, and collision stay server-owned.

Why a dedicated server when the goal is Steam P2P?

Hosting is a role, not a build. The same HostSession drives both a player-hosted listen server and this dedicated one, so the dedicated flavor can be tested against the Kubernetes cluster today without waiting on Steam appid approval. Steam later swaps the Transport implementation, not the game logic.

How does the server avoid shipping terrain over the wire?

Both sides bake collision from the same seed using the shared q::worldgen height generator, so terrain is never transmitted. Static bodies are also culled from snapshots — a fixed body cannot move, so replicating it is pure bandwidth.

How do the two network lanes differ?

Reliable traffic (join, welcome, reject) rides a WebSocket over TCP; unreliable traffic (snapshots and inputs) rides UDP datagrams and is allowed to drop. The host hands each client a one-time token over the reliable lane, the client echoes it in a datagram, and the host binds token to address. If the datagram handshake never completes, or a payload exceeds one packet, that traffic falls back to the WebSocket — degraded but still playable.