Add browser builds with persistence, WebGL2, and YOG cross-play - #203
Add browser builds with persistence, WebGL2, and YOG cross-play#203genixpro wants to merge 14 commits into
Conversation
36d3687 to
94cdfbe
Compare
|
This is genuinely exciting. A browser build is the single biggest thing we could do for Some things I want to be explicit about before this lands, mostly because the cost of Generated map layouts change. ADR 005 states this outright. I accept the reasoning: Two screen-execution models now coexist. ADR 003 is honest that C++20 coroutines in the loader. Networking was rewritten. Small thing: three surviving files lose their What reassures me most is the shape of your evidence, not its volume: freezing the old One expectation to set: the description defers production hosting and operations, and the Thank you for the ADRs and for the candour in |
|
So what's the goal of this? Instant custom games and campaigns (single player) from our website or YOG and LAN, too? The former would already be awesome and could be its own branch if the consequences for merging this to master were somehow too controversial. |
495a657 to
3dcbde1
Compare
5dd4fe9 to
bd49479
Compare
|
Thanks — the intended scope is now clearer after cleaning the branch up. This PR makes custom games, campaigns, saves/imports, and the existing YOG flow work in the browser. YOG goes through the WebSocket gateway and supports browser/native cross-play. LAN is compiled out of the WebAssembly UI: browser sandboxing does not provide the direct TCP/listen/discovery model that Glob2 LAN uses. For generated maps, YOG clients do not independently regenerate the selected map. The host supplies the Longer term, I would like the browser release to grow a web-native multiplayer entry flow on top of this cross-play foundation: shareable match links, guest or lightweight identity, and instant matchmaking without requiring account creation first. That product flow is follow-up work rather than part of this implementation cleanup. I also restored the three copyright lines you identified and added API documentation to the new |
cfd2e9d to
7962c30
Compare
Giszmo
left a comment
There was a problem hiding this comment.
Requesting changes, but I want to lead with what this review is not about.
I was worried about the +16,330 / −4,203 and about what a browser port would cost the desktop
game. I built both sides and measured rather than guessed, and on those two questions the PR
holds up: the simulation is bit-identical and the engine gets slightly simpler. The four
changes below are dependency and packaging issues, not architecture.
Requested changes
1. OpenSSL is now a hard requirement for every desktop build — please put it behind a flag
SConstruct:166 appends to missing and fails the build if OpenSSL is absent, for any
server=0 configuration. There is no flag and no fallback, so every packager on every
platform now needs OpenSSL to build a client that may never open a wss:// connection.
The size cost is concentrated in one file. Attributing the binary growth per object on the
link line:
src/net/WssTransport.o 828,479 bytes of .text
all 362 other shared .o +162,873 bytes total (+5.5%)
WssTransport.cpp is 168 lines, but it includes boost/asio.hpp, boost/asio/ssl.hpp and
four boost/beast headers. The linked binary goes 5,486,152 → 7,656,528 bytes (+40%, .text
+1.50 MB) and picks up libssl.so.3 and libcrypto.so.3.
I am not asking you to drop it — NativeTransport::open (src/net/NetTransport.cpp:106)
selects WssTransport for a wss:// address, so this is reachable code that desktop
cross-play needs. I am asking for a build option (default on is fine) so a distro or a
minimal build can compile the client without OpenSSL and Boost.Beast, with
NativeTransport falling back to TCP-only.
2. debian/control was not updated
Build-Depends still has no libssl-dev, and nothing under darwin/ changed. The CI jobs
install libssl-dev and mingw-w64-x86_64-openssl explicitly, so CI stays green and the
first thing that breaks is a package build. Please add it (and whatever the macOS bundle
needs) in this PR.
3. Explain the regenerated determinism baselines
games/cross-replay.game is unchanged, but both baselines were replaced:
tests/baselines/cross-replay.replay: 256,668 → 610,606 bytes, diverging at byte 24tests/baselines/cross-replay.checksums: same size, identical for the first 4,668 bytes,
then 9,209 differing bytes
That reads alarming, so I checked it before writing this: the engine is bit-identical on that
exact save (evidence below), so this is a new, longer recording rather than a behaviour
change. But no in-repo test compares against cross-replay.checksums — the only in-repo
consumers of tests/baselines/ are two Playwright specs that use the .replay as an import
fixture. The oracle these files serve lives in the sibling cross-replay tooling, so replacing
them silently rebases something outside this repo.
Please say in the PR description why they were re-recorded and confirm the sibling tooling
was regenerated to match. If they did not need to change, revert them.
4. Hoist the per-tile work out of the FertilityCalculator kernel loop
Job::advance (src/FertilityCalculator.cpp:82) spends its operations budget per kernel
cell, and kKernelSide is 31 — so 961 iterations per tile. Each of those calls
State::coordinate() (:72), which is cursor / map.getH() and cursor % map.getH().
Map::getH() returns a runtime member (src/map/Map.h:155), so that is real integer division
961× per tile where the old nested loop did it once, plus a coordToIndex and distance[]
lookup that used to be hoisted out of the kernel loop.
I did not benchmark this, so treat it as a code reading. The scope is narrow — only maps older
than FILE_FORMAT_VERSION_PRE_FERTILITY reach it, at load time (src/Game_io.cpp:306) — but
computing x, y and the grass/reachability test once per tile instead of 961 times is a
small change and keeps the incremental version honest against the one it replaces.
Evidence for the parts I am not asking you to change
Merge base 84a9b8b6 vs head 7962c30e, both built scons -j4 release=1 server=0, identical
flags (-O3 -std=gnu++20), native Linux.
Simulation is bit-identical. Headless --nox, SDL_VIDEODRIVER=dummy:
| run | master | this PR | final checksum |
|---|---|---|---|
20,000 ticks, cross-replay.game (SmallForTwo, 2 AI) |
1680 / 1650 / 1656 ms | 1672 / 1664 / 1668 ms | c44b9dfa both |
20,000 ticks, gd-large-4ai.game |
6643 / 6558 ms | 6599 / 6632 ms | 3d4ce420 both |
1,500 ticks with GLOB2_CHECKSUM_SIDECAR=1 |
— | — | 3,660,536 bytes of per-tick, per-unit and per-building checksums, cmp-identical |
Save files cross-load in both directions: a save written by this branch loads on master and
yields the same checkSum() = 302ef289 after 500 ticks.
The line count is not engine code. By destination: browser/ +3,251, data/ +2,614, tests
+2,593, docs and ADRs +1,153, build system +840, deploy and CI +385. Shared engine code moves
+5,494 / −4,070 across 225 files, net +1,424. src/ + libgag/ total goes 134,145 → 136,076
lines, +1.4%. Unit.cpp, Map.cpp and Building.cpp are untouched; Game.cpp is −1 line.
There are 11 __EMSCRIPTEN__ sites in shared code across 6 files, and browser/ ships ~267
lines of JavaScript against 2,031 lines of Playwright tests.
Two things I expected to be debt and are not. src/net excluding the test suite goes
2,507 → 2,088 lines, and that 2,088 already includes the new WssTransport.cpp and
gateway/Gateway.cpp; native-only networking is about a third smaller, and
add_net_thread_message.py — a 103-line code generator — is gone. And the screen migration is
not the half-finished state I assumed from ADR 003: Screen::execute is now implemented on top
of the nonblocking primitives rather than duplicating them, and direct blocking execute( /
runModal() call sites drop from 44 to one (src/Engine.cpp:95, the end screen).
scons release=1 server=0 screen-test session-test builds clean here, and
ScreenExecutionHarness plus test/run-engine-session-test.py give 12 PASS and exit 0.
Non-blocking notes
libgag/src/GraphicContextCompound.cpp:85adds
if (!surface->textureInfo) glState.setTexture(surface->texture);, but theelsebranch
eight lines down binds the same texture, andGLState::setTexture
(libgag/src/GraphicContext.cpp:80) early-returns when unchanged. Harmless, just redundant
on the native path.CooperativeTask::promise_typeallocates ashared_ptr<State>inget_return_object()that
Awaiter::await_suspendimmediately overwrites with the parent's for every child task. One
wasted allocation per subtask.- The cmd+Q / alt+F4 handling moved from
SDL_GetModState()toevent.key.keysym.mod. That
reads intentional — synthetic and replayed events need the event's own modifier state — but
it is a macOS/Windows behaviour change that no CI job covers. - The new
webjob (Emscripten,npm ci, Playwright across chromium + firefox + webkit,
pulseaudio, 60-minute timeout) is a real recurring CI cost and a new flake surface. Worth
deciding on deliberately rather than inheriting.
Thank you for the ADRs and for status.md. Being able to read your reasoning for screen
execution, cooperative loading and generation randomness before reading the diff is what made a
349-file PR reviewable at all.
Opus 5 helped authoring this review.
7962c30 to
81f91fb
Compare
|
Addressed the requested changes in the rebased five-commit series:
CI is running on |
|
On item 3 (the regenerated determinism baselines):
The fixture predates all of that, so it fails the floor check outright once rebased past any of those merges. That's not cosmetic: Separately: this branch is still pinned at On the sibling cross-replay tooling: that's @kylelutze's Rust reader, not something in this repo, so I can't regenerate or verify its oracle myself. Kyle — could you either grant visibility into it so this can be checked directly in future PRs, or regenerate/confirm its side against the baseline once this branch lands on the new bytes? Happy to ping you again once the next rebase (to version 94) is up. I don't think this last piece should gate the merge decision here — the in-repo tests that actually load this fixture are covered, and the Rust-side sync is tracked as a follow-up rather than something this PR can resolve on its own — but I wanted it on record rather than left implicit. |
Introduce the browser shell and pinned Emscripten toolchain, isolate native and web build outputs, and add the bounded WebSocket gateway. Keep the native build usable alongside browser artifacts.
Drive native and browser entry points through the shared application host and owned screen stack. Convert loading, editing, map generation, and related UI flows to cooperative work that can advance across browser frames.
Connect the existing framed YOG protocol through browser WebSockets, retain native TCP and WSS peers, schedule multiplayer navigation safely, and add the tested gateway deployment configuration and cross-play coverage.
Integrate browser-specific services with the shared source layout, make persistence failures recoverable, support validated file import and export, resize active screens at frame boundaries, and add opt-in WebGL2 context recovery.
Finish settings, audio, navigation, replay-save, and multiplayer edge cases; remove Asyncify; expand native and cross-browser regression coverage; refresh reviewer documentation; and omit machine-specific results, screenshots, and handoff logs from the PR.
The linux and windows jobs still pointed run-savegame-safety-tests.py at build/src/TrappedUnitLifecycleTest[.exe], the pre-build_layout.py path. Every sibling harness step in the same jobs already moved to the per-toolchain/role/mode layout; these two were missed, so both jobs failed immediately with FileNotFoundError. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
92439b6 to
ecc35f7
Compare
master's tip (#223, fetch-job apportionment) bumped VERSION_MINOR and REPLAY_MINIMUM_VERSION_MINOR to 94 after this branch's last recording at 93. ReplayReader::open() rejects anything below the floor, and browser/tests/import.spec.js and session-reload.spec.js both import this exact file and expect it to load. Re-recorded from the unchanged games/cross-replay.game (seed 42, SmallForTwo, Econo vs Nicowar) run to its natural end via `--nox games/cross-replay.game 0 1`, with GLOB2_CHECKSUM_SIDECAR_MAX_TICKS=100 to match the existing checksum sidecar's cap. cross-replay.checksums came out byte-identical to the prior recording — expected, since the sidecar only covers the first 100 ticks and Giszmo's review already established the simulation is bit-identical between master and this branch on this exact save. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
|
Rebased onto master's current tip ( Verified locally: native client and server both build clean ( On item 3: CI is running on |
39 keys added for import/export and durable-save-failure UI (Loading headers, saving to storage, storage restore failed, etc.) had only their English source text copied into every non-English catalog. master's newer test_catalogs_do_not_reintroduce_english_fallbacks (pulled in by the rebase) flags any non-English catalog whose value for a key equals the English source and isn't reviewed shared vocabulary — this predates the rebase but was never checked before, since that test didn't exist on this branch until now. Translated all 39 keys into all 32 non-English catalogs. Two catalog codes don't follow ISO 639-1: texts.si.txt holds Slovenian, not Sinhala (the shipped fonts have no Sinhala glyphs at all, confirmed by test_font_coverage.py, and none of its existing content is Sinhala either); texts.sr.txt is Serbian in Cyrillic, matching its existing content, not Latin. Verified: check_translations.py --strict (0 structural errors), test_translations.py, test_font_coverage.py, and test_text_area_layout.py all pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
|
@Giszmo — this should be ready for another look now. Summary of everything since your review: Your 4 requested changes:
Also since your review, the branch turned out to be 5 master commits behind (not the 1 I first assumed), including the Settings (#236) and custom-game lobby (#237) redesigns landing around the same time — full rebase onto master's current tip is up now, with Native client and server both build clean locally, headless run is deterministic, and all four translation checks pass. CI is running now on |
Giszmo
left a comment
There was a problem hiding this comment.
My judgement is colored by wanting this to be a thing on globulation2.org so I don't give approval but I hope @stephanemagnenat does ;)
CI caught what local scons/server builds didn't: none of the explicit harness targets (custom-setup-test, session-test, etc.) build under a bare `scons`, so the earlier rebase verification never compiled them. - test/CustomGameSetupHarness.cpp: fix six ScreenStack constructions left argument-less by the earlier mechanical patch (ScreenStack has no default constructor); rewrite the driver-based UI test to push CustomGameScreen onto a real ScreenStack and drive it via ScreenStack::execute(), matching SinglePlayerFlow::custom(), since Engine::initCustom(void) no longer exists. - Match speed selection was lost in the CustomGameScreen merge: master's redesigned lobby has CustomGameScreen::selectedSpeed(), but neither SinglePlayerFlow::custom() nor the old Engine::initCustom(void) it replaced actually applied it. Added an optional speed parameter to Engine::initCustomTask() (sets/restores previousCustomSpeed, mirroring the removed method) and wired it through SinglePlayerFlow and the test. - test/EngineSessionHarness.cpp used SettingsScreen::OK, an enum from the pre-#236 widget-based screen this branch never knew about; master's redesign has no such enum. Replaced with a direct done() call. - SettingsScreen::done() closed synchronously once local writes succeeded, never actually waiting on the browser persistence flush added during the rebase reconciliation — contradicted this exact session-test assertion ("Settings must poll persistence before closing"). done() now always confirms durability via persist() and only calls endExecute() once any pending flush resolves; onTimer() drives that completion (or reopens editing on a flush failure). Verified: all 24 harness targets CI builds compile clean locally, and engine-session-test (including "settings close only after persistence completion") and custom-setup-test both pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
CustomGameSetupHarness (4 invocations) and BuildingExpelHarness were still on the pre-build_layout.py path, missed by the earlier trapped-unit-test fix and the merge that introduced these two steps. Audited every remaining harness path in the workflow against the build_layout.py <toolchain>/<role>/<mode> scheme; no others were stale. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
…irty
CI's browser job failed 28 Playwright specs, almost all with the same
symptom: the very first "open Settings, close it untouched" in a fresh
profile never returns to MainMenuScreen (30s timeout). The one directly
relevant spec that passed ("settings wait for durable storage before
closing") deliberately stalls the storage write before ever attempting
a real one — every spec that lets a real, unstalled first close happen
hangs, including specs about unrelated screens/flows that just happen
to touch Settings once during setup.
Master's persist() only calls settings.save()/saveKeyboardLayout() when
the corresponding dirty flag is set. The original (pre-redesign)
savePreferences() this replaced always called them unconditionally.
Every other caller of persist() (commit(), finishInteraction(), the
onTimer retry path) already has at least one dirty flag set by the time
it calls persist(), so gating never mattered there — done() calling
persist() on a completely untouched screen was the one path this
skipped real writes on, and it's exactly the failing scenario. Also
restored the try/catch the original had around this whole sequence,
which persist() had dropped.
I can't run the actual browser/Playwright suite from here to confirm
this is the full fix rather than a partial one; pushing it to let CI
verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
The 28 failing browser Playwright tests all shared one symptom (Settings never returned to MainMenuScreen), which I first misdiagnosed as a persist() bug. Running the actual Emscripten+Playwright toolchain locally (rather than guessing from CI logs) showed persistStorage() was never even being invoked: the tests click stale pixel coordinates left over from before master's Settings redesign (#236). A screenshot of the real 1200x900 layout put "Done" at (1015,777), nowhere near the tests' (600,650). Root-caused via direct instrumentation (Module.persistenceResults, Module.storage.state) and confirmed by clicking the real coordinate. While fixing the coordinates I found a genuine behavioral gap the redesign introduced: the old screen had a Cancel/Continue button that always closed in one click, independent of the save outcome; the new footer only had "Done" (gated on success) and no equivalent. Added it back as SettingsScreen::abandon(), wired to an always-visible footer button that relabels itself "Continue" once a save has actually failed, reusing the existing "[settings continue]"/"[settings Cancel]" translated strings. Verified empirically: Done still blocks while failed and retries on every click; the new button never retries and always closes in one click, matching storage.spec.js's single-click "restore failure" test and settings-storage.spec.js's "continue after failure" test. Added a formula-based clickSettingsDone/clickSettingsCancel helper to main-menu.js mirroring SettingsScreen::layout()'s panel/footer math, so the click position tracks the panel across any viewport instead of a hardcoded pixel. Verified against real screenshots at 1200x900, 900x650 and 1100x700. Also fixed a test-design bug this surfaced: the redesigned screen auto- saves on every change, so settings-storage.spec.js's fault-injection tests were injecting the storage fault *after* the dirty click, letting the write land before the fault ever applied. Moved fault injection before the change in both affected tests. Added pixels.js:hasDarkText for the redesigned screen's dark-text-on-paper footer status line (the existing hasLightText assumed the old dark-panel/ light-text styling). Verified locally with the full Emscripten/Playwright toolchain: - settings-storage.spec.js: 5/5 pass - shutdown-storage.spec.js, storage.spec.js's restore-failure test, viewport.spec.js's Settings-touching tests: all pass - Native release build (scons release=1) and web build (scons target=web release=1) both clean. CustomGameScreen (master's other redesign, #237) has the same class of stale-coordinate problem in single-player.spec.js, storage.spec.js and rendering.spec.js; that's unstarted and tracked separately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
Continuing the same investigation as the Settings fix: the CustomGameScreen tests failed for the same reason (stale coordinates from before master's lobby redesign, #237), but digging into the one test I couldn't fix with a coordinate swap turned up a real crash bug and a real launch bug, both now fixed and covered by tests. Bug 1 - AI profile picker crashes the browser tab: CustomGameScreen::showAIProfile() (wired to each colony's "Info" button) opened its picker via the old choose()/Screen::execute() blocking-loop pattern. The browser host has no Asyncify, so ApplicationHost::wait() inside that loop is a hard error there (docs/browser/adr-003-screen- execution.md) - clicking Info threw an uncaught exception that unwound past the scheduled-stack driver, freezing the page on that screen with no further input reaching it. #237 added this button without a browser build to test it against; #203 is the first PR that makes CustomGameScreen reachable in one. Fixed by pushing CustomGameChoiceScreen through the ScreenStack CustomGameScreen already holds, same as every other dialog this screen opens, instead of blocking-executing it. choose() is now unused and removed. Verified with page-error listeners: no crash, correct CustomGameScreen <-> CustomGameChoiceScreen transitions, and the picked AI now visibly sticks (confirmed via screenshot: colony row updates from "Numbi - Easy" to "Warrush - Medium" after Use). Bug 2 - launching a randomly generated map fails to load: Engine::initCustomTask(MapHeader, GameHeader, ...) never forwarded a source file path to initGameTask, so GameGUI::loadFromHeaders fell back to deriving one from the map's display name. For a premade map that coincidentally matches a real file (maps/FourSquares1.map), so it worked by accident; for a generated map ("Random map" -> maps/Random_map.map, which never exists) it always failed. Fixed by threading CustomGameScreen::sourceFile() (already tracked, just never passed through) through initCustom/initCustomTask into initGameTask's sourceFileName, in both SinglePlayerFlow::custom() and the harness driver. This also makes premade-map loading correctly use the actual selected file instead of a name-based library guess, rather than working by luck. Verified locally: - CustomGameSetupHarness's ui-mode driver (SDL-event-driven, exercises the Info button and a random-map launch end to end) now passes for all three controller modes; previously failed identically with or without the showAIProfile fix, confirming it's Bug 2, not a regression from Bug 1's fix. All of the harness's other CI-invoked modes still pass. - Native release build, server build, and the web build all clean. - Full local Playwright run across every previously-failing CustomGameScreen test (single-player, storage, rendering, viewport, import, input, replay-save, session-reload): 35/35 pass. Added CustomGameScreen::start()'s footer button as clickCustomGameStart() in main-menu.js (mirrors renderLobby()'s button rect, verified against a screenshot) and used it everywhere a test just needs to launch the preselected default map, replacing the old two-click stale-coordinate sequences. Rewrote single-player.spec.js's AI/rules test around what the redesigned lobby actually exposes: "other options" are now inline Game Rules rows (no separate screen to navigate to and back from), and the AI picker is exercised for real now that it doesn't crash - this doubles as the regression test for Bug 1. import.spec.js's "imports a custom map ... through the normal setup screen" test still needs attention separately: the redesigned CustomGameScreen has no import affordance at all anymore (that lives only in ChooseMapScreen's "Load" flow), so the test's premise - importing directly into the lobby - has no current equivalent to test. Left unstarted pending a decision on whether that's a feature gap to close or a flow the test should follow into ChooseMapScreen instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
…bby button CustomGameScreen has no import affordance anymore after #237's redesign (map browsing there is now "Premade maps"/"Your maps" library tabs, not a file picker) - only ChooseMapScreen still has one, and that screen only offers map-type import when the editor's "Load Map" opens it. Rather than add a new lobby affordance or drop coverage, route the test through the path that actually exists: import via the editor's map chooser, back out without loading it into the editor, then pick it up from the custom-game lobby's own "Your maps" library and start a match with it. Verified the full round trip locally, including that the newly-imported file shows up correctly in the lobby's map preview before starting. Full local Playwright run across import/input/replay-save/session-reload (11 tests, the remaining files touching CustomGameScreen): 11/11 pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
Globulation 2 can now run as a full-page WebAssembly browser client while sharing the existing game, UI, save, map, replay, and YOG logic with native builds. The browser supports single-player play, durable local persistence and import/export, an opt-in WebGL2 renderer, and browser/native YOG cross-play through a WebSocket gateway.
This branch is reconstructed from the working browser implementation as five reviewable commits on current
origin/master. The rewrite removes development diaries, handoff notes, screenshots, machine-specific benchmark output, stale migration commentary, and historical checklists. It preserves the existing implementation and formats while fixing current-master integration issues and adding focused safety coverage.Commit series
Each commit completes a native release build independently.
Behavior and compatibility
?renderer=webgl2selects the WebGL2 path, including context-loss restoration.wss=0produces a TCP-only client without OpenSSL or Boost.Beast and fails closed for WSS addresses. LAN is compiled out of WebAssembly because browsers cannot provide its direct TCP listener and discovery model.Build and run
Open
http://127.0.0.1:8765/for software rendering or add?renderer=webgl2. Seebrowser/README.mdfor the pinned toolchain, tests, gateway setup, and troubleshooting.A hosted single-player build is available at Launch Globulation 2. Multiplayer gateway hosting and a project-website Play button are not included in that demo. A later browser multiplayer experience can build shareable match links, lightweight guest identity, and instant matchmaking on top of this PR’s YOG cross-play foundation.
Validation
Local validation on the rewritten branch includes:
PR CI keeps the expensive coverage bounded: one WebAssembly build, the complete Chromium behavior suite, focused Firefox/WebKit compatibility checks, focused Chromium WebGL2 and visibility coverage, and a 60-minute cap on the browser job. Native builds remain in the repository’s existing compiler lanes instead of being repeated in the browser job. Browser executions were reduced from 359 to 110, the prior build-order matrix and automatic daily run were removed, and large development artifacts upload only for manual runs with seven-day retention.
The final four-job CI run for the rewritten SHA is the merge gate.