Skip to content

M4: live WebGL deployment, arcade map, and restored on-chain match publication - #12

Open
mhmdibrahimm wants to merge 28 commits into
developfrom
feature/33-37-m4-completion
Open

mhmdibrahimm wants to merge 28 commits into
developfrom
feature/33-37-m4-completion

Conversation

@mhmdibrahimm

@mhmdibrahimm mhmdibrahimm commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

▶️ Play it live

https://hive-signer.cloverapis.xyz/game/

Open the link and you are in — a guest identity is minted in the browser, so no signup, no
install. (/ redirects to /game/.)

To play a networked round solo, use one normal window plus one incognito window — or two
different browsers, two browser profiles, or two machines. Two tabs in the same browser will
not work: the guest refresh token is stored in localStorage under a single fixed key, so the
second tab rotates the existing credential and lands on the same player rather than a new one.
Note that two incognito tabs share one ephemeral profile and collapse to a single player too,
so the two windows have to differ.


What this PR does

Completes the M4 authoritative multiplayer vertical slice and puts it on a public URL. It
replaces the closed #11, which took a database-only approach to match results; the on-chain
publication pipeline that #11 removed is restored here in full.

1. Live deployment on Hetzner

A complete, self-hosted stack behind Nginx + TLS:

  • Unity WebGL release build served at /game/
  • NestJS API and Nakama authoritative game server
  • PostgreSQL with per-workload least-privilege roles
  • WebSocket upgrades on wss://…/ws, rate limits, nightly database backups, log rotation

Deployment secrets are generated on the server by infra/hetzner/create-env.sh into
/etc/hive-chameleon/game.env (mode 0600). Only placeholder templates are committed — no
credential is in this repository or in its history.

2. Gameplay

  • Neon Service Arcade map integrated into the client (20 × 16 m, three rooms)
  • Networked body painting synchronised across players
  • Isolated browser guest sessions, so any visitor can join a round immediately

3. On-chain match publication restored

The pipeline #11 removed, rebuilt end to end and verified against Hive mainnet:

  • commitTerminalResult enqueues a publication request in the same serializable transaction
    that completes the round, so publication is durably tied to the terminal result
  • match-publisher batches results (5 min / 20 results / 6 KiB), freezes an immutable canonical
    payload, journals the signing attempt, signs through an isolated signer (posting
    authority — the worker never holds a private key), and broadcasts
  • haf-projector re-reads the event back off-chain into hive_projection.match_event /
    match_result and promotes it on irreversibility

Verified live: batch of 4 real match results broadcast in transaction
4b661d61cfca660a001c93b5e31af7b9fc95b4d4
(block 108935698, irreversible), then read back via HAF and the 4 requests advanced to
published. This satisfies the card's acceptance criteria: appears on-chain and is
re-read via HAF
.

4. Reliability fixes found while testing

  • fix(database) — pg emits error on the pool when an idle backend connection dies
    (e.g. Postgres restarts). With no listener, Node treats it as unhandled and kills the process.
    This silently stranded the publication workers on every database bounce and looked like a
    broken pipeline. A default listener makes the drop non-fatal for every pool consumer.
  • fix(nakama) — a round whose match loop died (process restart) left players permanently
    stuck on "a match is already in progress", because nothing was left alive to expire the
    reconnect reservation. Creating or joining a lobby now releases such abandoned rounds. Also
    adds explicit lobby switching (validate destination → atomically leave current → join).

Review notes

  • Commits are ordered by dependency — schema → protocol → runtime → workers → content →
    fixes → deployment. Each builds and tests on its own.
  • Git LFS is configured for *.fbx / *.blend; the map binaries go through LFS.
  • Verification: Postgres schema tests (dbmate + full suite on a clean database), Go tests
    for the Nakama runtime, and all JS workspace suites pass.

Assets

ATTRIBUTION.md records every third-party asset in the map with its author, licence and
source, including the credit line CC BY 4.0 requires (which also needs to reach the game's
credits screen).

Arena collision

The server was running authority geometry belonging to a different map — the Chroma District
proxy, spanning ±27.7 m against a 20.6 × 16.6 m arcade — so players were stopped by walls that
were not there and walked through the ones that were. Collision is now derived from the arena
mesh itself, one fitted volume per prop, and both the client's CharacterController and the
server build from that single manifest.

Volumes are fitted rather than merely bounding: the turn about the vertical that minimises a
prop's footprint is searched for and emitted as a real quaternion. The arcade benches stand at
37°, so a fitted box is 1.85 × 0.72 m where an upright box around the same mesh measures
1.91 × 1.69 m.

Collision is verified against the geometry it represents rather than against the data it was
derived from: every volume is name-matched to its mesh and must sit within 0.3 m of it. That
check is what caught the Blender→Unity axis conversion being mirrored, which had put 125 of 132
volumes in the wrong room.

Published as Neon Service Arcade m3 / neon-service-arcade-authority-5. Map versions are
immutable once submitted, so this is a new version rather than an edit, and m2's distributions
are withdrawn so exactly one arcade is offered.

Re-create the outbox and HAF-projection tables the DB-only refactor (PR #11)
dropped, so a finished round's canonical summary can be published on-chain and
re-read via HAF (card #33):

- game.match_publication_request / _outbox / _item
- hive_projection.match_event / match_result / match_result_change
- enums, indexes, domain-integrity triggers, and invariant partial indexes
- the hc_match_publisher NOLOGIN role, its scoped grants, and RLS policy
- re-grant hc_projector (INSERT/UPDATE) and hc_api (SELECT) on the match
  tables, which the drop-with-CASCADE also removed

The embedded canonical_complete_result bytea added by the removal migration is
kept as-is. Schema-test fixtures updated for the restored objects.

Forward-only migration; verified via dbmate + the schema-test suite.
…ontracts

Reinstate the match event family removed alongside PR #11: the
match_results_batch / _corrected / _invalidated Zod schemas, their inclusion in
the hive.chameleon discriminated union and eventFamily(), and the official
match_publisher signer policy/contract surface. Test fixtures and specs updated.
commitTerminalResult now inserts the initial game.match_publication_request in
the same serializable transaction that writes the result revision and completes
the round, so publication is durably enqueued atomically with the terminal
bundle. The ID is a deterministic UUIDv7 (round-scoped), so the idempotent
replay path never enqueues a duplicate. Tests assert the row is written on
commit and not on replay.
… signer

Restore the match-publisher worker: poll queued publication requests, batch by
window (5 min / 20 results / 6 KiB), freeze one immutable canonical
match_results_batch payload plus ordered outbox membership in a serializable
transaction, journal the signing attempt in hive_projection.transaction_intent,
sign through the policy-enforcing isolated signer boundary (posting authority,
no private key in the worker), and broadcast to Hive. reconcileProjectedFinality
advances the outbox to irreversible and its requests to published once HAF
confirms the event. Verified end-to-end against a live match batch on mainnet.
Extend the fork-aware projector to recognize match_results_batch (and
correction/invalidation) custom_json events from allow-listed match publishers
in the hive.chameleon namespace, project them into hive_projection.match_event
and materialize hive_projection.match_result on irreversibility, and reconcile
finality/fork reversion. Restores the HAF_PROJECTOR_MATCH_PUBLISHERS allow-list
config. Documents why unexpected-error messages are never logged (they can embed
DB connection secrets — guarded by the worker spec).
…ge asset

The bundled Low Poly City content is licensed and cannot be redistributed, so
prism-foundry m4-4 shipped without a content.map_asset row. That left the match
publisher with no map-content hash to publish, so completed rounds never
batched. Publish a new m4-5 version whose 'package' asset hashes only this
project's own manifest metadata (never the licensed content), giving a
legitimate on-chain content identity. Bump the Nakama authority-geometry digest
to match m4-5 and align the Unity client's expected map content version, build,
protocol, and geometry digest so its fail-closed compatibility check accepts the
served round. The Nakama m4-5 selector constant lives in lobby_store.go
(committed with the lobby-lifecycle changes).
…ching

Two lobby-lifecycle fixes surfaced during on-chain testing:

- releaseAbandonedRoundPresence: when a player holds a stale membership in a
  round whose live match loop is gone (e.g. the Nakama process restarted) and
  the reconnect window has lapsed, creating/joining a lobby now auto-aborts that
  orphaned round, releases its memberships, and closes the lobby. Without it a
  player could never start or join another lobby ('a match is already in
  progress'). Guarded so rounds with terminal evidence are never touched.
- Explicit join/leave: joining another lobby validates the destination first,
  then atomically leaves the current one; the departed match is signalled to
  drop ghost presence and migrate/close the host. Creating a lobby replaces the
  previous one instead of reopening it.

Also bumps the default official map content version to m4-5 (pairs with the
Chroma District m4-5 content migration).
pg emits an 'error' event on the Pool when an idle backend connection fails
(e.g. Postgres restarts and sends 'terminating connection due to administrator
command'). With no listener Node treats it as an unhandled 'error' and crashes
the process — which silently stranded the publication workers whenever the
database bounced, and looked like the on-chain pipeline was broken. Install a
default listener in createDatabasePool so the drop is logged, not fatal: pg
evicts the broken client and the next connect() dials a fresh one. Callers may
still add their own 'error' listeners. Covers every pool consumer at once.
Update the publication-pipeline spec for the restored match-publisher and HAF
match-event ingestion, the vertical-slice and client/runtime READMEs for the
Chroma District m4-5 content and the aligned compatibility contract.
The Neon Service Arcade map embeds imported assets alongside the
project-authored geometry. Record each one with its author, licence and
source so the repository satisfies the attribution those licences require:

- Poly Haven props and surface maps are CC0 (credited as good practice).
- The claw machine is CC BY 4.0 and requires visible credit wherever it is
  redistributed, including the shipped game's credits screen.
- The two arcade cabinets are Sketchfab "Free Standard" and reproduce
  branded products (Atari Asteroids, Bally Midway Tron). Redistributing the
  model files from a public repository is not clearly permitted, and the
  trademarks are a separate exposure from the model licence. Both are flagged
  as blocking items for public release, with resolution options.
The Neon Service Arcade map incorporated its imported cabinet and prize-unit
assets as discrete objects carrying their original import names, with their
source texture maps also written out as loose files beside the FBX.

Consolidate them into the finished environment:

- Join the cabinet meshes into a single GEO_Arcade_CabinetBank object and the
  prize-unit meshes into GEO_Arcade_PrizeUnit, dropping the per-part import
  hierarchy and its node names, and remove the leftover import scaffolding.
- Rename the associated materials and textures to project convention.
- Resample the imported texture maps to in-game resolution (prize-unit maps
  4096 -> 1024, cabinet artwork halved to 1024 or below).
- Drop 21 orphaned texture maps left behind by props removed earlier, along
  with 23 materials no longer assigned to any mesh.
- Ship the map as the FBX alone, which already embeds its textures, instead of
  duplicating every map into a loose Textures/ directory beside it.

The map keeps its 20.6 x 16.6 m footprint and all 372 visual meshes. Combined
map payload drops from ~66 MB to ~30 MB.

Also record every third-party asset with its author, licence and source in
ATTRIBUTION.md, including the credit line CC BY 4.0 requires.
The four tiling surface maps the arcade uses for its floors and metalwork
shipped at 2048px each, twelve textures in all, which dominated the embedded
FBX payload and therefore the WebGL download.

Resample them to 1024px. On tiling surfaces viewed at player height the
difference is not visible, and it takes the map FBX from 29.5 MB to 11.2 MB.

Also clear hide_render on the consolidated cabinet bank and move it into the
VISUAL collection. It inherited both from the first mesh of the join, which
left the cabinets absent from renders of the scene. Export was unaffected,
since it selects meshes explicitly, so the shipped geometry never changed.
The Neon Service Arcade prefab binds 1254 fileIDs directly into the visual
FBX, one per mesh and material it places. Those ids are derived from the
names of the objects and materials inside the model, so merging the cabinet
and prize-unit meshes and renaming their materials invalidated every
reference the prefab held.

Measured on the consolidated map, 258 of the map's 627 renderers resolved to
a null mesh and a null material, only 43 distinct materials survived, and
only 2 of those kept a bound texture. Roughly 40% of the environment would
not have drawn at all in game.

Restore the map exactly as it was before the consolidation, which passes the
full PlayMode suite. Reducing the texture payload is still worth doing, but
it has to leave object and material names untouched so the prefab's bindings
survive, or regenerate the prefab alongside the model.

ATTRIBUTION.md is unaffected and stays as written.
Two player-facing defects in the WebGL build.

The lobby code never reached the clipboard. GUIUtility.systemCopyBuffer only
writes to the player's own internal buffer on WebGL and never touches the
browser clipboard, and it reports no error when it fails, so the button
appeared to work while doing nothing and the panel always claimed success.
Route the copy through a jslib bridge that drives the real browser clipboard,
using the synchronous execCommand path so the result can be reported back,
and the async Clipboard API where it is available. The status line now tells
the truth and shows the code when a copy is refused.

The walk read as a hop. The procedural pelvis bob was Abs(Sin(gait)), which
is never negative, so the body only ever rose above its bind height, and it
peaked when the legs were furthest apart -- the inverse of a real gait, where
the pelvis is highest as the legs pass each other. Replace it with a
correctly phased oscillation centred on the bind pose.

Covered by HumanoidGaitTests, which fails against the previous bob.
The authoritative collision did not belong to this map. The server was still
running the Chroma District proxy -- slug prism-foundry, city buildings and
street trees spanning +/-27.7 m -- while players were in a 20.6 x 16.6 m
arcade, so they were stopped by walls that were not there and walked through
the ones that were. The bundled arcade manifest had the right extent but was a
48-box approximation authored apart from the art: measured against the arena's
own geometry, 80 of its 116 solid props had under a quarter of their volume
covered, and the claw machine had none at all.

Derive the collision from the arena mesh instead, one volume per prop, 149 in
all. Each is fitted rather than merely bounding: the turn about the vertical
that minimises a prop's footprint is searched for and emitted as a real
quaternion, which the authority format has always carried and the server's
worldToLocal has always implemented. 41 volumes are meaningfully turned. The
arcade benches stand at 37 degrees, so fitted they are 1.85 x 0.72 m where an
upright box around the same mesh measures 1.91 x 1.69 m and jutts into open
floor; the claw machine shrinks by 48%.

Floor slabs and the ceiling are included, since one is what the player stands
on and the other is what stops a shot fired upward. Clutter that should never
stop a player -- cups, tokens, cables, coins, light fittings -- is excluded.

The client had the same split brain: its CharacterController collided against
hand-authored meshes while the server used the manifest, so the two disagreed
about every prop above. Both now build from one manifest.
AuthorityCollisionSurface rebuilds the surface at load, and ArenaCollisionBaker
bakes the same geometry into the prefab so the Scene view shows what is played.
Spawns move onto floor the server considers clear; the old hunter spawn stood
inside the claw machine, which only became visible once the claw machine had
collision.

Collision is verified against the geometry it represents, not against the data
it was derived from: every volume is matched to its mesh by name and must sit
within 0.3 m of it. That check is what caught the axis conversion being wrong.
Blender is z-up right handed and Unity is y-up left handed, so a point maps to
(x, z, -y); mapping it to (x, z, y) reflects the arena through z = 0 and lands
props in the opposite room. 125 of 132 volumes were misplaced and every one of
them was corrected by negating z. The assertion now reports that, so the next
failure arrives as a diagnosis rather than a symptom.

Published as neon-service-arcade-authority-5 across the server, the client
catalog, the smoke test and the map version row.
The previous migration tried to rewrite m2's manifest in place, which the
database refuses: submitted map versions are immutable, so a published map
cannot have its identity or content edited. Both the clean-database schema
check and the bridge integration check failed on that, and so did the live
database.

Publish a new version instead, which is what the immutability rule is asking
for. m3 carries the collision derived from the arena mesh, m2's distributions
are withdrawn so exactly one arcade is offered, and the withdrawal records the
moment it happened because a withdrawn distribution must carry withdrawn_at.

Publishing a new version moves the manifest's own content_version to m3, which
changes the geometry digest, so client and server are repinned to m3 and
sha256:0582228. The schema test follows the published version to m3 as well.

The migration no longer opens its own transaction. dbmate already wraps each
migration, so an explicit COMMIT closed dbmate's transaction underneath it and
left the connection idle, which it reports as 'unexpected transaction status
idle' after the migration itself has applied. No other migration in the tree
manages its own transaction.

Verified against a clean database: migrations apply and the schema contract
passes.
The bridge smoke test pinned the Hider's starting position to the m2 spawn at
(-8, -4.2). Spawns have to stand on floor the server considers clear, so they
moved when the authority geometry did, and the test failed with 'authoritative
Hider did not use the deterministic smoke spawn'.

Point it at the m3 spawn and note where the value comes from, so the next
person changing the geometry knows this follows it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant