Dual-Client Test: Native Desktop and Three.js Against One Godot Zone Server
Superseded by: 20260425-headless-test-matrix.md
- Status: superseded (not being built)
- Deciders: V-Sekai, fire
- Tags: V-Sekai, Testing, Playwright, Godot, Threejs, DualClient, ZoneServer, 20260425-dual-client-test
Why superseded: the design pairs a Godot native client with a Three.js browser client. The Three.js client is no longer being built (see 20260425-threejs-webgpu-zone-client.md). Dual-client coverage now lives in the headless test matrix as the GO+GP pair (20260425-headless-test-matrix.md).
The Context
The jellyfish game has two clients: a Godot native PCVR client and a Three.js WebGPU browser observer. Both connect to the same Godot zone server via WebTransport. The pass condition requires both to be visible simultaneously. Testing them separately proves each works; testing them together proves they interoperate against a shared server state.
The Problem Statement
There is no test that runs both clients against one server at the same time and verifies they observe the same entity state. Without it, a server-side bug that affects only one client could go undetected.
Design
Test topology
Zone server (Godot headless)
├── Godot native client (headless, reads CH_INTEREST, writes to stdout)
└── Three.js browser client (Playwright Chromium, reads CH_INTEREST via WebTransport)
One server, two clients, one Playwright test orchestrating all three.
Process setup in Playwright
import { spawn, ChildProcess } from "child_process";
async function startServer(): Promise<ChildProcess> {
return spawn(GODOT_BIN, ["--headless", "--path", ZONE_PROJECT, "scenes/zone_server.tscn"]);
}
async function startNativeClient(): Promise<ChildProcess> {
return spawn(GODOT_BIN, ["--headless", "--path", DEMO_PROJECT, "scenes/observer.tscn",
"--", "--zone-host=127.0.0.1", "--zone-port=17500", "--dump-state=/tmp/native_state.json"]);
}Three.js client in Playwright
const { server, port } = await serveWithCOOP(THREEJS_DIST);
const page = await browser.newPage();
await page.goto(`http://localhost:${port}/`);
await page.waitForFunction(() => (window as any).__entities?.length > 0, { timeout: 15_000 });
const browserEntities = await page.evaluate(() => (window as any).__entities);The Three.js client writes parsed CH_INTEREST state to window.__entities each frame, accessible via page.evaluate().
State comparison
Both clients read from the same server. After one server tick, both should observe the same set of global_id values and matching positions within floating-point tolerance:
const nativeState = JSON.parse(fs.readFileSync("/tmp/native_state.json", "utf8"));
const browserState = await page.evaluate(() => (window as any).__entities);
const nativeIds = new Set(nativeState.map((e: any) => e.id));
const browserIds = new Set(browserState.map((e: any) => e.id));
expect([...nativeIds].sort()).toEqual([...browserIds].sort());
for (const ne of nativeState) {
const be = browserState.find((e: any) => e.id === ne.id);
expect(be).toBeDefined();
expect(be.pos[0]).toBeCloseTo(ne.pos[0], 1); // 10 cm tolerance
expect(be.pos[1]).toBeCloseTo(ne.pos[1], 1);
expect(be.pos[2]).toBeCloseTo(ne.pos[2], 1);
}Position tolerance is 10 cm, larger than the double-precision encoding error and small enough to catch a wire decode bug.
Godot native client state dump
The observer scene needs a --dump-state flag that writes the last received CH_INTEREST batch to a JSON file on exit. This is a one-function addition to operator_camera.gd or a new headless_dump.gd script:
func _on_fabric_client_state(entities: Array) -> void:
if OS.has_feature("headless"):
var path := OS.get_cmdline_user_args().filter(
func(a): return a.begins_with("--dump-state=")
).map(func(a): return a.split("=")[1]).front()
if path:
FileAccess.open(path, FileAccess.WRITE).store_string(JSON.stringify(entities))Playwright spec structure
dual_client.spec.ts
setup: start zone server, wait for port open
test 1: native client connects, receives entities, dumps state
test 2: Three.js client connects, receives entities, exposes via window.__entities
test 3: both run simultaneously; compare entity sets
teardown: kill server and native client
The Downsides
Three processes running simultaneously in CI is noisy. Zone server startup time adds ~3 s to the test. The --dump-state flag needs implementing in GDScript.
The Road Not Taken
Testing via a mock server: rejected; a mock cannot reproduce the actual server’s entity scheduling, HLC timestamping, or AOI band behaviour. The dual client test must use the real server.
Status
Status: Superseded by 20260425-headless-test-matrix.md. Not being built.
Decision Makers
- iFire
Further Reading
(jellyfish_pass?): 20260425-jellyfish-pass-condition.md — the pass condition this test validates.
(threejs_observer?): 20260425-threejs-observer.md — Three.js observer client design.