Co-op slop in a livinggrassland
A Godot 4.7 co-op game on a lightweight GDScript ECS with a Rust gdext core — deterministic worldgen, GPU-streamed grass, toon shading, and server-owned online sessions — shipping Windows, macOS, and Linux to itch.io on every release.
Q — the Rust core
The q gdext crate owns terrain, grass, physics, netcode and now the Steamworks bridge — QSteam registers on every build and simply answers unavailable outside the Steam flavor.
- Deterministic worldgen — seed in, same hills out, on every machine.
- One simulation — solo and online step the same Rust sim.
What it gives you
Features
GDScript ECS
addons/GodotECS — observer hubs and entity relations driving gameplay.
Procedural grassland
GPU-streamed grass, flora and trees over deterministic terrain from the q crate.
Server-owned online
Intent-only movement against an authoritative server; the seed is the only terrain ever shipped.
Steam-ready art
Every capsule renders from the live game world in one nx pass — see the gallery below.
What it is
Overview
A Godot 4.7 game driven by a small GDScript ECS (addons/GodotECS) with observer hubs and entity relations, toon-shaded rendering, and procedural grassland biomes.
CI
Build & Ship
ci-main.yml reads the CI dispatch manifest and hands this entry to ci-godot.yml whenever the MDX version outpaces version.toml. The godot pipeline runs the gdUnit4 suite headless via scripts/test.sh, exports the Windows Desktop, macOS, and Linux presets in the barichello/godot-ci container, and pushes each build to kbve/friendslop on itch.io with butler. gdUnit4 and the tests/ directory are excluded from exports via the preset exclude_filter, so the test harness never ships in the finished product.
Front door
Title screen & identity
scenes/title.tscn is the front door: the real world generator drifting behind a paper button column, not a render of one — same QTerrain, same materials, same sky, so it costs what the game costs. The orbit rig doubles as the streaming anchor the terrain and grass fields normally take a player for.
The backdrop runs none of what the world scene runs — no player, no physics step, no creatures, and none of the tree, flora, shrub or rock fields — so title_screen.gd spends that budget on grass instead: GRASS_BOOST (1.8×) on density and RANGE_BOOST (1.35×) on the distance tiers, applied relative to the saved tier so a phone on Low still gets a phone’s worth of grass. Mobile takes no boost at all; its budget was already gone. The boost re-applies on GraphicsSettings.changed, because apply() writes the tier’s own density straight onto the field and would otherwise undo it the first time a player opened Settings.
A QFishField swims the river under the bridge — the same field the world scene uses, turned up (560 fish, 52 pods, school_chance 0.45) since nothing here is scaring them.
Identity lives in the Auth autoload (src/autoload/auth_session.gd):
| Mode | Token | Name |
|---|---|---|
SIGNED_OUT | — | — |
GUEST | "" | the server answers with Anon-XXXX |
ACCOUNT | Supabase JWT | kbve_username, read from the claims |
Signing in happens in the player’s own browser. GoTrue runs with GOTRUE_SECURITY_CAPTCHA_ENABLED=true (hCaptcha), so the password grant refuses any client that has no browser to solve a challenge in — it answers captcha protection: request disallowed. sign_in keeps that path for the day that changes; nothing calls it.
sign_in_with_provider runs the loopback OAuth flow, PKCE so that no client secret ships in the game:
OAuthLoopbackbinds an ephemeral port on127.0.0.1— never0.0.0.0, which would let the rest of the network answer the redirect.OS.shell_opensends the browser to/auth/v1/authorize?provider=…&redirect_to=http://127.0.0.1:PORT/callback&code_challenge=…&code_challenge_method=s256.GOTRUE_URI_ALLOW_LISTalready permitshttp://127.0.0.1:**.- The provider redirects back to the loopback port with
?code=…; the socket serves a small “close this tab” page and shuts down. POST /auth/v1/token?grant_type=pkcewith{auth_code, code_verifier}returns the session. The verifier never leaves the process until this step, so a code caught by anything else on the machine is worthless.
Which providers appear is the server’s call, not a list in the client. enabled_providers() reads GET /auth/v1/settings once and the panel drops any button GoTrue reports as disabled, so a provider turned off upstream stops offering a sign-in that could only fail. Every failure path — no autoload, a transport error, a non-200, a body that is not a dictionary, an empty external map — prunes nothing and leaves the full list standing. A provider still has to be in PROVIDERS to appear at all, because each one needs a name, a tint and an icon the game ships; enabling one upstream will not conjure a button.
Google and Apple are the two the store builds will need. Apple requires an equivalent privacy-preserving login wherever an app offers social sign-in, which in practice means Sign in with Apple on iOS, and Google sign-in is what Play users expect. Both are currently false in GoTrue, and neither has brand art here yet.
Neither is only a matter of switching them on. The loopback flow above cannot survive the trip to a phone: OAuthLoopback binds a 127.0.0.1 port and waits, which assumes a desktop that keeps the process running and lets a browser reach its own machine. iOS backgrounds the app the moment Safari takes over and expects ASWebAuthenticationSession; Android expects a Custom Tab returning to a registered deep link. The mobile work is a second redirect path — a custom URL scheme the app claims, added to GOTRUE_URI_ALLOW_LIST beside http://127.0.0.1:** — and the loopback stays for desktop. export_presets.cfg already carries iOS and Android presets; sign-in is what is not ready for them.
Tokens live in memory only — a refresh token in user:// on a shared machine is a login. adopt_account is the same seam a stored session would land on.
The token is what the server verifies; the username decoded on this side is only for what the title prints before the join. See friendslop-server for the verification half.
scenes/title.tscn is now the boot scene (run/main_scene). The pause menu’s Log Off already returns here from either world.
Rendering
Shadows & foliage
Daylight shadows crawled across every surface while the player stood still. The sun’s rotation was written from the raw hour angle each frame while light_angle_step_deg only gated the energy lookup, and a directional shadow map is reprojected from the light’s rotation with no texel snapping — a hundredth of a degree per frame reshuffles every edge. The rotation now derives from the same quantized step the lookup does, and the day runs 45 minutes rather than 10, which is 0.13°/s of sun instead of 0.6.
That left an artifact only on foliage, because both tree shaders discard in fragment() and both set ALPHA_SCISSOR_THRESHOLD — so that function runs in the shadow pass too. CAMERA_POSITION_WORLD is the light’s camera there, not the player’s. The leaf shader’s camera-occlusion fade therefore ran a second time along the sun-to-player ray and cut a metre-wide disc out of whatever canopy it crossed: a patch of sunlight under the tree, centred on the player, tracking them as they walked. The screen-space LOD dither had the same problem, writing stochastic noise into the map instead of a silhouette.
Godot exposes no builtin for “this is the shadow pass”, so the ViewGlobals autoload publishes the active camera as a view_position global and the shaders compare it against CAMERA_POSITION_WORLD. Wind flutter is gated the same way — 2.2 cm of per-vertex displacement at 4.5 Hz buzzed every leaf shadow edge, and the shadow now uses the unfluttered card.
The canopy itself was rebuilt around what a card actually is. Leaves have always spawned only at branch tips, off a recursive skeleton with Murray’s-law taper, but leaf_cluster had no idea which twig held it — every tuft was a sphere, so the crown collapsed into one mass with the branches buried. Tufts now lay their cards on an ellipsoid along the twig, and cards are fewer and larger: at 9 cm they were smaller than a shadow texel in the outer cascades, so no bias setting could stop them aliasing.
Cards were also square while thirteen of the fifteen euonymus_alpha_* textures are 256×512 single leaves, so two of the three species were painting every leaf at double width. Extents now come from the texture’s aspect, area-preserving.
Lighting is foliage lighting rather than surface lighting: backfaces are flipped by FRONT_FACING before the lift toward world up (the shader is cull_disabled, so half of every card had been shaded facing the wrong way), the texture contributes luminance instead of hue so a species’ colour stays its own, light is wrapped rather than lambert, cast shadows land on a tinted floor instead of dropping to ambient, and light travelling through a leaf toward the camera adds a warm term in place of a flat BACKLIGHT. Every value is a material uniform.
Characters got the same class of fix. cel_shading.gd builds lit materials with shadow_threshold = 0.0, putting the toon terminator exactly at dot(NORMAL, LIGHT) == 0 — the lighting silhouette, where the normal turns fastest per pixel — and shadow_softness of 0.005 made that band a fraction of a pixel wide on a limb. A boundary narrower than a pixel cannot be sampled stably. It now widens to the screen-space derivative of N·L when that is larger, so camera-facing surfaces keep the authored hard edge and only grazing and distant ones open up.
Multiplayer
Online sessions
Play as Guest enters scenes/online.tscn; Singleplayer enters scenes/main.tscn. The split is authority, not content — online, the server owns both where bodies end up and what the world looks like.
Terrain is never shipped. Welcome carries a seed, online_world.gd writes it onto QTerrain.terrain_seed, and the client bakes the same heightmap the server is stepping collision against — which is the entire reason the generator is deterministic. A client that baked a different one would walk through hills the server can still see.
| Piece | Job |
|---|---|
src/net/online_world.gd | Auth → connect, seed → terrain, roster → nameplates |
src/net/net_avatar.gd | One body: nameplate, and a velocity derived from the position delta so remote players animate |
src/net/net_camera_rig.gd | Third-person follow; its yaw is the frame movement intent is expressed in |
src/net/online_hud.gd | Connecting / joined / rejected, who else is here, and how many robots you have out |
src/net/net_pet.gd | One deployed robot: chassis, and a walk cycle and facing derived from how it moved |
Movement stays intent-only: NetGameClient rotates the input vector by the camera’s yaw and sends a direction, never a translation. Defaults to wss://friendslop.kbve.com/ws; FS_URL points it at a local server.
FS_URL=ws://127.0.0.1:7980/ws bash scripts/godot.sh res://scenes/online.tscnCompanions
Pet robots
G deploys a robot beside you, H recalls the lot. The server owns all of it — where they land, how they steer, and whether you may have another — so the client only picks a chassis and draws what comes back. A refusal arrives in the server’s own words and lands on the HUD for a few seconds.
A robot is told from a player by its body id, not by looking it up in the pet list. Bodies arrive in the snapshot, which is unreliable and frequent; the list is reliable and only sent on change, so a pet’s body can turn up first. A client that waited for the list to recognise one would spawn a player avatar for a robot and then be stuck with it. QNetClient3D therefore emits pet_added and pet_removed separately from body_added, decided on the reserved band alone.
What the list does carry is the chassis and whose it is, so a robot whose entry has not landed yet stays unbuilt rather than settling on the wrong model and never revisiting it.
Neither the facing nor the walk cycle is on the wire — the character proxy never turns, so its pose says nothing about which way the thing is looking. Both come from how the body actually moved, and the rig is turned rather than the node, which the extension overwrites from the snapshot every frame.
NPCs
The villagers' day
The eight people at the crossing have a timetable rather than a walk. A routine is a list of stops — an hour, and an offset from wherever the world placed them — authored in each NPC’s npcdb MDX and codegen’d into assets/npcdb/npcdb.json with the rest of them. q/src/routine answers one question from it: given the hour, where is this person and are they walking?
That shape is deliberate, and it is what makes the crossing safe to put online. A stepped walk is local state, so two clients drift and a late joiner starts at stop one while everybody else is mid-afternoon — the same failure as two players under different suns. A routine derived from the clock has no state to disagree about: every machine reads the same hour and computes the same answer, a late joiner lands in phase, and nothing has to be sent. The protocol has carried elapsed for exactly this since the sky needed it.
day_night.gd therefore keeps monotonic world seconds in both modes — accumulated locally when solo, taken from the host when driven — and hour_seconds() converts a day length into how long an hour lasts, so a longer day stretches the walk rather than teleporting people.
The engine’s half is small on purpose: an NPC walks toward wherever the clock says they should be, at up to a small multiple of their speed. On schedule that tracks it exactly. Off schedule — held for a conversation, or spawned mid-morning — the same rule catches them up without a second code path.
A conversation stops the walk. It is released when the talk ends, and also when whoever started it walks away, because rest() is wired to the dialogue panel closing: a talk that never opens would otherwise leave somebody standing still for the session.
Stops now carry work. A stop may name a task — the animation performed while standing there — and a yield_item with yield_minutes, the itemdb ref the work produces and how many game-minutes of it each one costs. Wren harvests herb through the morning and bellflower after noon, Tam chops logs, Marlow holds a lantern over the evening deck. Yields derive from the clock the same way positions do — the crate reports how long somebody has stood at their current stop, and each time another work-period completes, one item lands beside them through GroundItems, where a player can take it. Joining late does not spill the backlog: the first observation of a stop baselines the count, so only work performed while somebody is there to see it hits the ground.
Stops get the same rescue stands do — a stop that lands under the waterline is walked out to dry ground before anybody is sent to it. Offsets are relative, so the same +6 is dry beside one person and midstream beside another.
Villagers now take a body in the simulation: the same spawn_character capsule the player and the creatures use, on the creature layer, masked against the world and everything with a body. Walking goes through move_character and the sim owns the ground under them, so nobody walks through a villager and a villager walks around whatever is in the way. Without a sim — tests, Q_GODOT_PHYSICS — the direct walk remains, so the timetable does not depend on the physics being there. While spoken to they turn to face whoever is talking.
Not yet done: NPCs are still only in main.tscn. The moment one of them does something — produces goods, takes damage, holds a quest item — the clock stops being enough and the host has to own them, because that depends on what players did rather than what time it is.
Physics
One simulation, and what is left of the second
Solo and online used to run different simulations, and most confusing creature behaviour traced back to that. Solo has since moved onto q’s SimWorld — the player and the creatures both — so the split is mostly closed. What follows is where it stands, because a half-migrated system is the easiest kind to reason about wrongly.
QPhysics3D in q/src/rapier/bridge3d.rs is the way in. It carries spawn_character, move_character, teleport_character and character_grounded alongside the rigid-body spawners, and static collision for the stone, tree and road fields.
Solo (scenes/main.tscn) | Online (scenes/online.tscn) | |
|---|---|---|
| Player | spawn_character / move_character | server-owned |
| Creatures | spawn_character / move_character | PetRegistry (q/src/net/pets.rs), host-side |
| Steering | QPatrol per node | PetRegistry, one pass per tick |
| Neighbours | _crowd(), a group scan per creature per frame | one map built per tick, sliced per pet |
| Formation slot | @export written at spawn | recomputed every tick from the roster |
Q_GODOT_PHYSICS=1 keeps both the player and the creatures on move_and_slide, so the two paths can be run against each other rather than argued about. Keep it working — it is the only way to tell a physics regression from a steering one.
Still on the Godot side. _crowd() builds each creature’s neighbour list by walking get_nodes_in_group() every physics frame, which is O(n²) in GDScript and allocates per creature. pets.rs already does the same job once per tick and slices it. Formation slots are still @exports written at spawn, so two spawners or anything spawning at runtime will collide or go stale, where the pet path recomputes them from the roster for free.
Creatures pass collision_layer / collision_mask into spawn_character, and they still sit on LAYER_CREATURE without masking it — so they do not collide with each other, in SimWorld either. Do not re-add depenetration. It was removed because it fought the solver: the solver opens a gap, depenetration closes it along the shortest axis, and on a slope that axis is often vertical, which parks one machine on top of another where it reports is_on_floor and stays. Avoidance is the solver’s job and it has the whole picture; depenetration has none of it.
Already measured — do not re-derive. The steering solver was cleared under pet tuning, Config::default(), and the solo creature tuning; with the flow field on and off; and with _crowd()’s cull box on and off, which changes nothing to the decimal. Host-side pets on flat ground hold 2.2–3.3 m with zero overlaps and no climbing. Two real defects were found elsewhere and fixed: creatures cached get_gravity() as zero on their first frame and never fell, and the turn gate scaled the whole wish vector — travel and avoidance — so a group all turning at once lost most of its separation at the moment it was converging, measured at 2.75× weaker.
Known weak. step_characters in q/src/rapier/sim3d/world.rs resolves every character against the world as it stood at the start of the tick and applies the results afterwards, so no character sees another’s new position that tick. That mattered little when only pets were characters; the player and every creature are characters now. And slopes remain the untested case — the measurements above were all flat ground, and the failure this design is most exposed to is specifically a vertical one.
Already measured — do not re-derive. Steering was cleared under pet tuning, Config::default(), and solo creature tuning; with the flow field on and off; and with _crowd()’s cull box on and off, which changes nothing to the decimal. Host-side pets on flat ground hold 2.2–3.3 m with zero overlaps and no climbing.
Still unknown. Everything above was flat — slopes, the documented failure case, are untested on both paths. And step_characters in q/src/rapier/sim3d/world.rs resolves every character against tick-start state and applies afterwards, so nobody sees anyone else’s new position that tick; that window gets more crowded the more bodies move in.
Tuning is now one source. Config::mech() and Config::pet() in q/src/steering/mod.rs are the only places the numbers are written; PetConfig::default() takes the second, and Godot asks for the first by name through QPatrol.use_preset("mech"). The creature_patrol.gd and creature_spawner.gd tuning exports are gone, and the steering tests run Config::mech() rather than Config::default(), so a probe and the game exercise the same numbers.
Pointing the tests at the shipped tuning immediately failed a_following_group_does_not_walk_through_itself — 45 overlapping ticks out of 1200, closest approach 2.78 against a 3.2 body. Isolated by varying one field at a time:
| tuning | closest | overlapping ticks |
|---|---|---|
Config::default() | 3.647 | 0 |
| as shipped | 2.777 | 45 |
shipped, personal_space 3.0 | 2.870 | 79 |
shipped, formation_spacing 9.0 | 3.641 | 0 |
shipped, roam_radius 22.0 | 2.777 | 45 |
formation_spacing alone. creature_spawner.gd was assigning its placement number to it — patrol.formation_spacing = spacing, where spacing = 13.0 exists only so mechs do not spawn intersecting. Ranks tuned that wide make followers cross each other on a leader turn. Placement and formation are now separate: the spawner keeps spacing for putting them down, and the preset carries 9.0 for holding a rank.
One number stays per-creature rather than in the preset: set_body(radius), because Godot builds the capsule off the mesh and so Godot is what knows.
Dev loop
Local
pnpm nx run godot-friendslop:test— import + gdUnit4 headless suitepnpm nx run godot-friendslop:test:smoke— raw SceneTree smoke passpnpm nx run godot-friendslop:export— all three release exports
scripts/gdext.sh skips the build when addons/q/<platform>/libq.* is already there, and a fresh LFS checkout smudges a released one — so a worktree can run the whole suite against an extension that predates whatever you are testing, and fail on parse errors about members that plainly exist. GDEXT_FORCE=1 bash scripts/gdext.sh rebuilds it.
Store art
Steam page assets
Every image below is rendered from the live game world by src/ui/steam/page_assets.gd — pinned seed, real terrain, real foliage — and regenerated in one pass with pnpm nx run godot-friendslop:steam:assets. Each figure is anchored, so feedback can point at a slot by its link. The title art is drawn at render time from the Alagard face, so the whole set re-exports under a new name with one argument.


















Questions
Frequently asked
What is Friendslop?
A Godot 4.7 co-op game built on a lightweight GDScript ECS with a Rust gdext core, featuring deterministic procedural grassland, toon-shaded rendering, and server-owned online sessions.
Where can I play Friendslop?
Windows, macOS, and Linux builds ship to itch.io at kbve.itch.io/friendslop through the CI godot pipeline; a Steam release is in preparation.
How does Friendslop handle multiplayer terrain?
Terrain is never shipped — the server sends a seed and every client bakes the identical heightmap from the deterministic generator, so all machines agree on the world.