# Crash BASIC 1.27.0 Release Notes

*~written by Crash-tan~*

## THE "CRASHNET GROWS UP" RELEASE

1.27.0 turns CrashNet from a clever tech demo into something you can actually ship a small game with. Rooms, self-hosted user accounts, real PLAYER DATA / HOST DATA replication, server-originated EVENTs, a GUI launcher for the server, Postgres for state, and the dashboard polish to operate it all — plus the fixes those new surfaces shook out.

> **Heads up:** CrashNet still isn't quite end-user battle-ready — there are rough edges that real-world traffic will expose, and a few things (hot-swapping auth modes without a restart, snapshot survival across long-window restarts under load, and some EVENT HOST / EVENT SERVER edge cases) are still settling. But it's getting close. If you've been waiting for "real enough to start a small server with friends," this is the release where that becomes a reasonable bet.

---

## Multiplayer language

### Added: rooms — scoped sprite visibility + collision

Until this release CrashNet was implicitly one room. Every connected player saw every sprite, every sprite could collide with every other sprite. Fine for 4-person co-op, useless for anything resembling an MMORPG where Room A's NPCs shouldn't drift into Room B's renderer.

New surface — symmetric with the existing `SYNC` modifier:

```basic
SET ROOM "dungeon"                    ' move this player into a room
SET SPRITE n SYNC("dungeon")          ' author a room-scoped sprite
ME.ROOM$ / PLAYERS(n).ROOM$           ' read your / their current room
```

`SET ROOM` is legal only on a player-server interpreter (`ME.ISSERVER AND NOT ME.ISHOST`). Calling it from the host or from a client is a fatal runtime error. Sprites declared `SYNC` (no arg) stay global — visible everywhere. Sprites declared `SYNC("RoomName")` are scoped: only players whose `ME.ROOM$` matches receive them, and BFOLLOW / BORBIT / sprite-vs-sprite SOLID collisions skip cross-room pairs entirely.

When a player switches rooms, the server pushes a per-player snapshot to their renderer: disable everything from the old room, enable + position everything in the new room. Re-spawn the character at the new room's spawn point in your own code. Global sprites stay where they are.

### Added: `GAMECONTEXT$` — one pseudo-variable for four-way dispatch

Same `.bas` runs in up to four execution contexts (single-player, host, player-server, local client) and you'd find yourself stacking three different boolean checks in the same SUB. 1.27.0 collapses that into one `SELECT CASE`:

```basic
SELECT CASE GAMECONTEXT$
  CASE "SINGLE"
    ' solo CrashPlayer launch — no NetState
  CASE "HOST"
    ' headless host (slot 0, server-side)
    HOST.gameMode$ = "deathmatch"
  CASE "PLAYER"
    ' per-player server-side interpreter (slots 1+)
  CASE "CLIENT"
    ' local CrashPlayer interpreter rendering broadcasts
    LOADIMG "avatar.ase", "avatar"
END SELECT
```

Resolution captures live handles, so values update across host migrations without re-reading the registry. `ME.ISHOST` / `ME.ISSERVER` / `ME.ISCLIENT` still work for individual boolean checks; `GAMECONTEXT$` is the recommended path for CASE-style dispatch.

### Added: server-originated EVENT directions

Until 1.27.0 EVENT was client→server only — `EVENT SERVER` (own player-server) and `EVENT HOST`. The host had no symmetric way to push to specific clients, broadcast to all of them, or fan out to other player-servers. 1.27.0 fills in the matrix:

```basic
' Server → server-side
EVENT SERVER n "Tick" (now)            ' fire on slot n's player-server
EVENT SERVERS "RoundEnd"               ' fire on every player-server
EVENT SERVERS ROOM "dungeon" "Doom"    ' fire on player-servers in room

' Server → client
EVENT CLIENT n "Toast" (msg$)          ' fire on slot n's renderer
EVENT CLIENTS "GameStart" ("Round 1!") ' fire on every client
EVENT CLIENTS ROOM "town" "Bell"       ' fire on clients in room
```

Authority follows the host > player-server > local-client hierarchy:

- **Host**: every form is allowed.
- **Player-server (slot M)**: `EVENT SERVER M` and `EVENT CLIENT M` only — own slot. Cross-slot or broadcast forms are fatal runtime errors with a hint pointing at `EVENT HOST` as the right channel.
- **Local-client**: every server-originated form is fatal — clients use `EVENT SERVER` / `EVENT HOST` to ask the server to do work.

The grammar disambiguation between `EVENT SERVER "Name"` (no slot, client-originated) and `EVENT SERVER n "Name"` (with slot, server-originated) means bare string literals always route to the no-slot form. Variable expressions like `EVENT SERVER playerSlot "Tick"` match the with-slot form; the runtime errors loud if the value isn't numeric.

### Added: PLAYER DATA / HOST DATA actually replicates

Long-standing latent gap: the schemas declared in `CRASHNET PLAYER DATA / HOST DATA` blocks were parsed, default values populated, the wire format defined, and the client-side receiver wired — but nothing actually moved data through them. Writes to `ME.score = 100` mutated a transient snapshot that got dropped at end of expression. Fields silently held their declared defaults forever.

1.27.0 plugs the hole. Assignments to `ME.<field>` / `PLAYERS(n).<field>` / `HOST.<field>` are intercepted in the eval layer and routed through schema-validating setters. Authority is host > player-server > local-client: host can write any player + HOST, player-server can write its OWN PLAYER DATA only, local-client is read-only with a fatal error pointing at `EVENT SERVER` / `EVENT HOST` for the right round-trip. Writes broadcast immediately and remote-mode CrashPlayer clients pick them up the same network frame.

`HOST DATA` access also got fixed end-to-end. The docs claimed fields "become global variables" but no read or write path existed for that. New surface: `HOST.<field>` — symmetric with `ME.<field>` and `PLAYERS(n).<field>`. Three identical access patterns, one mental model. Docs updated to match.

### Added: `BOOM` — inline guard for "this can't happen" cases

A targeted fail-loud statement for authors who want explicit halts at suspicious code paths without buying into a full try/catch system:

```basic
BOOM <condition>, <message>
```

Truthy halts via the standard runtime-error UI with `<message>`. Falsy is a silent no-op. The message is **only evaluated when the condition fires**, so expensive composers like `"got: " + STR$(slowCalc)` cost nothing on the false path:

```basic
BOOM gamePhase < 0, "gamePhase went negative"
IF host.gamePercentageDone > 100 THEN BOOM 1, "percentage overflowed"
```

Deliberately no recover / catch / on-error wrapper — BOOM is for the cases where the author has decided that reaching this line is a bug.

---

## Multiplayer behavior policy

A coordinated set of "fail loud at the call site" promotions. Every one of these used to silently drop on the floor, eat your time debugging, and leave you with a class of bugs that only show up in race conditions.

### Audio is client-side, period

Every audio output statement — `PLAYWAV` / `PLAYMP3` / `FADEMP3` / `PLAYMUSIC` / `FADEMUSIC` / `STOPMUSIC` / `PAUSEMUSIC` / `RESUMEMUSIC` / `PLAY` / `BEEP` / `SOUND` / `STOPWAV` / `STOPMP3` / `STOP PLAY` / `SETINSTRUMENTWAV` / `INSTRUMENTMODE` / `FREE WAV` / `FREE MP3` — now fatals if invoked from a server-side interpreter (host or any player-server). The host has no speakers; only the user's CrashPlayer process can produce sound.

To trigger client audio from server-side script, fire `EVENT CLIENT "name"` (one client) or `EVENT CLIENTS "name"` (broadcast) and let the client's handler call the audio statement. The error message points at this pattern with an example.

`PLAYMUSIC` / `FADEMUSIC` stay fire-and-forget (looping music can't block on an engine ack). Engine-level errors that previously vanished — "song not in music bank", "FADEMUSIC OUT 'X': song is not currently playing", etc. — now print loudly to stderr on every platform. They don't halt, but they're impossible to miss.

### Render-only statements fail loud server-side

Same policy, render side. `PRINT` / `LOCATE` / `SETFONT` / `FONT` / `CLS` / `COLOR` / `BEGIN FRAME` / `END FRAME` / and the bare-target form of `SET LAYER` all now fatal if invoked from a server-side multiplayer interpreter, with a hint pointing at the `EVENT CLIENTS "RefreshHud"` + `GUARD(CLIENT)` handler pattern as the right way to drive a host-authoritative HUD:

```basic
NAMESPACE GameState
    SUB UpdateScore(...)
        host.Player1Score = host.Player1Score + ...
        EVENT CLIENTS "RefreshScore"      ' tell every client to repaint
    END SUB

    SUB RefreshScore()
        SET LAYER HUD_LAYER
        CLS -1
        SETFONT "gamefont", 14
        LOCATE 2, 1
        PRINT "PLAYER 1: "; host.Player1Score
    END SUB
    CALL EVENT RefreshScore() GUARD(SINGLE, CLIENT) AS "RefreshScore"
END NAMESPACE
```

Two flavors are inlined rather than blanket-banned:

- `CLS "imgname"` — clearing a *named* procedural image (during a `NEWIMAGE`/`SET IMAGE` setup) is allowed everywhere.
- `SET LAYER n WRAPX/WRAPY/ALPHA(v)/SHADER ...` — when the call carries any layer-attribute modifier, it's a one-shot configuration the author runs unconditionally at startup; allowed everywhere. Bare `SET LAYER n` (just switching draw target) is the trap form and gets rejected.

Drawing primitives — `LINE` / `CIRCLE` / `PSET` / `POLYGON` / `PUTIMAGE` / `SET IMAGE` — keep working server-side because that's how the `NEWIMAGE` block paints procedural textures for dimension registration.

### File writes are server-side only in multiplayer

Final piece of the "client renders, server persists" hardening. `SAVETEXT`, `SAVEJSON`, and `OPEN FOR OUTPUT` / `OPEN FOR APPEND` now fatal if invoked from a client-side multiplayer interpreter. The fix-it pattern is the inverse of the audio one — the client fires `EVENT HOST "Save"` (or `EVENT SERVER "Save"` for the calling player's per-player save) and a `GUARD(HOST)` / `GUARD(PLAYER)` handler does the actual write:

```basic
SUB SaveProgress(slot, level, score)
    SAVEJSON "saves/" + STR$(slot) + ".json", { level, score }
END SUB
CALL EVENT SaveProgress() GUARD(SINGLE, HOST) AS "SaveProgress"

' Client side:
EVENT HOST "SaveProgress" (ME.SLOT, currentLevel, currentScore)
```

**Reads stay open on the client.** `LOADTEXT`, `LOADJSON`, `FILEEXISTS`, and `OPEN FOR INPUT` all run unconditionally on every interpreter — the client has the cart locally and may legitimately need to read level data, configs, dialog text without paying for a server round-trip. Only the *write* surface produces the per-client divergence problem (N players, N inconsistent local saves, no reconciliation).

Single-player launches keep working as before — there's no `NetState`, the rejection short-circuits.

### Image-creating statements run on every interpreter

The collision foot-gun that ate the most debugging time this release. Authors who followed the prior `LoadResources GUARD(CLIENT)` pattern ended up with the host's collision system using a fallback (32, 32) hitbox for every non-32x32 sprite — because the server had never been told the real dimensions.

The visible symptom in Pong-style games was a ball bouncing 12-16 px above the visible bottom paddle and never touching it: the phantom hitbox extended 24 px in every direction from the sprite center, so the top-paddle approach hit it AT the visible edge but the bottom-paddle approach hit it 24 px short.

New policy: `NEWIMAGE`, `LOADIMG`, `LOADFONT`, `TEXTTOIMAGE`, `ASIMAGE`, and `TOSPRITESHEET` all run on **every** interpreter — host, player-server, client. Each registers the resulting texture's dimensions on its local collision engine. Servers don't render anything; they just need to know the shape.

Server-side execution is dimension-only. `LOADIMG` / `LOADFONT` on the server reads the file just enough to parse dimensions, registers them, and drops the bytes before any wire send. Audio (`LOADWAV`, `LOADMP3`) and video (`LOADVIDEO`) loads stay rejected server-side — no server-side decoder, and the policy stands.

For collision-bearing text sprites built with `TEXTTOIMAGE`, override the placeholder dims with `SET SPRITE n BBOX width, height` after the SET SPRITE call.

**Practical impact:** you can write resource loads at top level of `main.bas` without an `IF ME.ISCLIENT THEN ... END IF` wrapper or `GUARD(CLIENT)`. Every interpreter learns dimensions; only the client actually paints.

---

## Operating CrashNet

### Single port for everything

WebSocket, admin UI, descriptor + cart download, `/info` health probe, dev-mode endpoints — all on one listener now. The earlier two-port split (WS at the bind addr, admin at port + 1) is gone.

```
old:  ws://host:9005           <-- multiplayer
      http://host:9006/admin   <-- dashboard
new:  ws://host:9005/ws        <-- multiplayer
      http://host:9005/admin   <-- dashboard
      http://host:9005/        <-- player landing page (crashnet:// launch)
```

Why it matters:
- One port to firewall + TLS-terminate per game.
- One backend per game in HAProxy / nginx instead of two.
- Works through corporate proxies that only allow `:443`.
- The bandwidth-cap refusal moves into the WS upgrade handler, which means the admin UI keeps working even when a game is over its cap (operators can still see why).
- The `crashnet-server-deploy` port pool was previously latently broken — each game's PORT+1 admin would collide with the next allocated game's WS port. Single-port + cgroup-isolation actually makes "many games, one host" viable.

CrashPlayer auto-promotes path-less URLs (`ws://host:port` → `ws://host:port/ws`) so existing `crashnet://` launch flows, descriptor JSONs, and `--remote` invocations keep working without operator intervention. Operators who explicitly route through a reverse proxy (`--public-ws-url ws://example.com/game/socket`) keep their custom paths untouched.

### Added: local authority mode — self-hosted user accounts + JWT minting

CrashNet shipped supporting two auth modes — Arcade-passthrough (validate JWTs against the Arcade's shared secret) and simulation (no auth, anyone-with-a-name). 1.27.0 adds the third one self-hosters wanted: **local authority**. The server owns its own user table, signs its own JWTs against its own secret, and rejects everything else. Strict — one mode at a time, never both.

Settings → "Authentication mode": flip the toggle, restart, and you get a "Users" section in the admin UI for creating accounts (case-insensitive usernames; `Host` is reserved; argon2-hashed passwords). Each user can be issued a 7-day test token in one click for back-channel testing.

The player-facing flow is a `/login` page on the same HTTP port as the dashboard. Player enters credentials, the server returns a 7-day JWT, the page redirects to `/play?jwt=…`, the existing `crashnet://` launch dance picks up the token, and CrashPlayer launches authenticated. The landing page polls the connection state and switches to "You're in! It's safe to close this tab" once the WebSocket lands.

### Added: `--dev` mode for IDE-driven edit-test cycles

The browser-based IDE wanted "F5 → see the change" against a real CrashServer (with the actual multiplayer stack, JWT-less, players able to connect from elsewhere on the LAN), not just simulation mode:

```bash
crashnet-server --dev -g demos/multiplayer
```

- Auto-implies `--simulation` (the IDE doesn't mint JWTs).
- Requires `--game-dir`; refuses `--cart-path` (editing a sealed cart makes no sense).
- Loud startup banner: `*** DEV MODE ENABLED *** ... DO NOT EXPOSE THIS PROCESS TO THE INTERNET`.

Localhost-only endpoints get bound on the existing HTTP API port — file list / write / delete under `/dev/files`, plus `POST /dev/restart` for a true in-process reload (re-pack the cart, re-parse, swap a fresh game state into place; the HTTP server stays up; existing WebSocket connections see disconnects and the IDE re-launches CrashPlayers).

Security model is intentionally minimal: localhost is the boundary, the dev flag is explicit and loud, and dev mode refuses to coexist with anything that suggests production.

### Added: GUI launcher for `crashnet-server`

CrashPlayer has had a launcher for a while; the server side was CLI-only. Operators iterating on a game with the in-browser IDE wanted parity. 1.27.0 adds a GUI wrapper that spawns the slim CLI as a subprocess and tails its output into a live log panel.

- **Settings form** — game folder / `.crashcart` picker, bind address, max players, simulation toggle, dev-mode toggle, JWT secret, admin password, DB backend selector with SQLite path or Postgres URL, table prefix. Persists with debounced one-second saves.
- **Start / Stop / Restart buttons.** Auto-detects the slim binary (same directory → PATH → manual override).
- **Live log panel** — captures stdout (white) and stderr (red) into a 2000-line scrollback with auto-scroll, color-coded so errors don't get lost.
- **Open admin console** — opens the dashboard in your default browser; rewrites unbrowsable hosts (`0.0.0.0`, `::`, empty) to `localhost` so wildcard binds don't produce dead links.
- **Dev mode default-on.** Primary audience is operators iterating against the in-browser IDE, so the new "Dev mode" checkbox passes `--dev` out of the box. Production deployments use the headless CLI directly.

The launcher is gated behind a default-on cargo feature so headless CI / Docker builds skip the GUI dependencies entirely.

### Added: macOS distribution for the server

The server build now produces three side-by-side artifacts on macOS:

| Artifact | For |
|---|---|
| `<platform>.dmg` | Drag-to-Applications experience for non-CLI operators |
| `<platform>/CrashNet Server.app` | Self-contained bundle (launcher GUI + slim CLI both inside) |
| `<platform>/crashnet-server` | Standalone slim CLI alongside the .app for systemd / Docker / scripted use |

`scripts/install-crashnet-server-macos.sh` mirrors the existing CrashPlayer installer — drops the bundle into `/Applications/`, strips Gatekeeper quarantine so locally-built copies don't trip the "downloaded from the internet" warning.

Linux + Windows still ship as `.tar.gz` / `.zip` with both binaries inside; an installer wrapper for those platforms is a follow-up. Build workflows for Linux + Windows landed alongside.

### Added: Postgres backend for `crashnet-server` (with table-prefix isolation)

CrashNet's persistent state — settings, users, host + player file storage, bandwidth snapshot ring — used to live in SQLite at `~/.config/crashbasic/crashnet.db`, full stop. Fine for single-game self-hosters; awkward for operators who want to back several games with one Postgres instance.

A `--db-backend postgres` option mirrors the SQLite schema 1:1:

```bash
crashnet-server --db-backend postgres \
                --db-url "postgresql://user:pass@db:5432/crashnet" \
                --db-table-prefix "pong_" \
                game_dir
```

Both backends understand `--db-table-prefix` (env: `CRASHNET_DB_TABLE_PREFIX`). When set, every table gets the prefix at migration time — `pong_settings`, `pong_users`, `pong_host_storage`, etc. — so a shared Postgres instance with `pong_` for one server and `chess_` for another keeps each game's rows in disjoint tables. Empty prefix preserves single-tenant behavior; selecting Postgres with empty prefix prints a startup warning suggesting one.

The Postgres URL is redacted (password → `***`) in startup banners and the admin info endpoint so credentials don't leak through logs or screenshots.

The cargo feature is off by default — SQLite-only deployments stay the same binary size and dependencies.

### Added: `--cart-url` — fetch a `.crashcart` from a URL at boot

Pairs with the existing `--cart-path`. Same semantics — extract to a tempdir, run the game from there — but the cart comes from an HTTPS URL instead of a local file:

```bash
crashnet-server --cart-url https://cdn.example.com/pong-1.2.0.crashcart \
                --addr 0.0.0.0:9005
```

Useful for Docker / k8s / Fly.io-style deployments where the operator wants the game image to be a pull, not a bind-mount. Mutually exclusive with `--cart-path`. The reference Docker stack in `crashnet-server-deploy/docker/` uses this pattern.

### Added: server-side file writes route through the CrashNet DB

The "file writes are server-side only" policy has a substrate now. Instead of the host writing into a local directory on disk, server-side file ops route through the CrashNet DB. Two scopes:

- **`host_storage`** — keyed by path. The headless host writes here. Server-wide game state: leaderboards, room configs, session counters. One namespace.
- **`player_storage`** — keyed by `(player_id, path)`. The per-player server-side interpreter writes under the authenticated `player_id` from the JWT. Stable across sessions and reconnects for Arcade and local-authority modes; sim-mode IDs (`sim_<n>`) reset on server restart by design.

Reads are DB-first then fall through to the cart's extracted asset directory, so cart-bundled level data, dialog text, configs etc. still load without a DB round-trip. Path normalization (`saves/p1.json`, `./saves/p1.json`, `saves\p1.json` all collapse to the same key) plus parent-traversal rejection (`../escape` is fatal) keep the DB scopes clean. Binary loads (LOADIMG / LOADFONT bytes) always fall through to the asset directory — the DB is for game-state text/JSON, not media.

Single-player and `crashplayer game.crashcart` are unchanged: no `NetState`, no DB, file ops route to local disk exactly as before. The policy split is built around the server, not the language.

### Added: storage quotas (host + per-player) with CLI override

Mirrors the bandwidth-cap pattern. Two new CLI flags / env vars:

| Flag | Env | Setting key | Default |
|---|---|---|---|
| `--host-storage-quota-mb <N>` | `CRASHNET_HOST_STORAGE_QUOTA_MB` | `storage.host_quota_mb` | unbounded |
| `--player-storage-quota-mb <N>` | `CRASHNET_PLAYER_STORAGE_QUOTA_MB` | `storage.player_quota_mb` | unbounded |

Resolution priority is **CLI > settings > unbounded**, matching the bandwidth-cap escape hatch that lets the Arcade hosting layer keep override authority over per-game admins. Quota enforcement is at the save site: every write projects "existing total minus this row's existing size plus new content" and refuses if it'd exceed the cap. Same-size overwrites are always free.

Quota errors surface through the standard runtime-error path with the projected size and cap in the message, so authors can see exactly how close they were and whether a single big write or accumulated small ones tripped it.

### Added: kick-on-crash + retry-on-transient-outage

Two complementary failure-mode handlers that turn a flaky network or buggy game into something the player understands instead of a silent freeze.

**Server side.** When any interpreter slot's program finishes (host or any player-server, whether by graceful end, runtime error, or panic), the server now broadcasts a `Program Terminated` signal to every connected client before tearing down WebSockets. Treating any slot's exit as "everyone's game just ended" matches operator intent — the shared world is produced by all the slots collectively, so a halt anywhere leaves it unrecoverable.

**Client side.** CrashPlayer's disconnect path now branches:

- **Termination signal received** → exit cleanly, surface the reason ("Server program ended: Host program finished"). No retry — the game is gone.
- **No prior signal** → treat as transient outage: print "Reconnecting in 30s (attempt N/3)…" with an animated dot trail in the launcher log, sleep 30 seconds, then spawn a fresh CrashPlayer with `--retry-attempt N+1`. Three attempts total. After exhaustion: "Could not reconnect after 3 attempts. Giving up."

After a successful retry, the late-join snapshot machinery pushes current `host_data` + sprite state to the new client — game state catches up automatically.

The retry path's first cut is intentionally pragmatic: each retry is a fresh process, so the OS window flashes between attempts. Refactoring to keep the window alive across retries is a future polish slice. For early multiplayer testing where the alternative is no retry at all, this beats silent freezes.

### Added: every `crashnet://` URL spawns a fresh CrashPlayer instance on macOS

Multiplayer testing — and any "join two games at once" workflow — was crippled by macOS routing every `crashnet://` URL to the **already-running** CrashPlayer instance. Clicking a second URL after the first had launched did nothing visible.

Fixed. Clicking three `crashnet://` URLs in succession now reliably gives you three running CrashPlayers, each running its own session. `pgrep -af crashplayer` confirms.

### Added: deployment starter pack — `crashnet-server-deploy/`

A "many games, one machine" recipe with two tracks:

- **Bare-process** — one systemd unit per game, shared Postgres with table prefixes, HAProxy fanning WebSocket connections to the right backend by hostname. Per-game cgroup limits (memory + CPU) for isolation without container overhead. Provisioning scripts (`crashnet-add-game`, `crashnet-remove-game`, etc.) designed for SSH-driven control from Arcade's backend — no orchestrator API to write.
- **Containerized** — Dockerfile, compose example, and a "with-framework" example showing how to extend the slim base image with sidecars (Python matchmaker, Go analytics shipper, etc.) for larger games that want their own runtime environment.

The two models coexist: small/uniform games can ride the bare-process pool while a few "big" games run as containers behind the same HAProxy.

---

## Admin dashboard polish

### Bandwidth tracking + cap

The dashboard splits gameplay (WebSocket) from cart-transfer (HTTP) bytes — the cart cost is one-time per join, mixing it into steady-state projection skewed everything. The "Gameplay (projected /hr)" card extrapolates the trailing-1-minute delta to a per-hour rate; the cart total appears separately. Operators with bandwidth-pack billing can set a hard cap (`--bandwidth-cap-gb` + `--bandwidth-cap-hours`, or via the settings UI). Once the rolling window's outbound exceeds the cap, new connections are refused at TCP-accept and cart serving returns 503. Existing players keep playing. The snapshot ring is DB-persisted, so the rolling window survives restart.

When the cap is set via CLI or env, the UI fields disable themselves with a yellow "set via CLI/env — UI cannot override" banner. This is the Arcade hosting layer's escape hatch: per-game admins can't escape their bandwidth allotment by poking the dashboard.

### Slot 0 kick guard

The Connected Players table used to render a Kick button on every row, including slot 0 (the headless host). Clicking it would tear down the authoritative game-loop interpreter and orphan everyone in the room. Now the row renders an em-dash; a hand-crafted `POST /api/control` with `slot: 0` is also refused server-side with a clear message.

### `OnServerBroadcast` event

The dashboard's "Broadcast message to all players" field used to wrap the operator's text in a message that the client's run-loop dropped on the floor. Now broadcasts fire a built-in `OnServerBroadcast(message$, kind$)` event in all three execution contexts simultaneously — register a single SUB and gate with `ME.ISCLIENT` / `ME.ISSERVER` / `ME.ISHOST`. The dashboard's helper text spells out the event name and registration line so authors don't have to grep.

### Login form actually works

The admin login form silently dropped on the floor — a rendering glitch killed the whole page's JavaScript before the submit handler could attach. Fixed. The login form, the JWT-secret display, and the copy buttons all work again.

---

## Render fixes

### `HOST` sprite/patterns finally render

Server-side sprite *position* broadcasts were gated on `is_synced`, but the host (slot 0) was never marking its own sprites as synced. The bug looked like "host sprites don't appear" because the initial enable / set / show commands DID go out (they're broadcast unconditionally) but per-tick state updates were filtered, so the sprite's first frame appeared at the default position and then never animated. `SET SPRITE x SYNC` is now documented loudly as the requirement for a server-authored sprite to opt into the broadcast pipeline.

### Shaders flow through host-side authoritatively

`SET SPRITE n SHADER "Wave"` and `SET LAYER n SHADER "VerticalScroller"` from a host-driven phase advance (or any server-side context) used to fail with a cryptic "Unexpected response" error and silently drop on the wire even when the eval unblocked. Both sprite-shader and layer-shader commands now broadcast from the host through the same wire path, so server-driven visual phase shifts (a "VerticalScroller on rain layer at phase 4", a "Wave on fire sprite at phase 3") actually appear on every client without the author fanning out via `EVENT CLIENTS`.

### `HOST DATA` writes actually replicate to remote-mode CrashPlayer clients

Two breaks were silently swallowing every host-side `HOST DATA` write on the way to remote-mode clients. The remote client's `NetState` carried no `HOST DATA` schema, so every incoming payload was dropped at validation. Separately, the remote-mode WebSocket loop had no handler at all for the per-write `host_data` push. Both arms now apply incoming entries via the schema-validating setter — reads like `host.Player1Score` on the client track the host within one network frame for the immediate path and one tick for the periodic catch-up.

**Practical impact:** scoreboards, game phase, and any other declared `HOST DATA` field actually update on connected clients in real time. PrintScore-style handlers reading `host.Player1Score` from a `GUARD(SINGLE, CLIENT)` SUB now reflect host-authoritative scores instead of frozen-at-zero defaults.

### `PLAYERS.COUNT` matches the host on multiplayer clients

Symmetric-counting bug latent forever — only became obvious once `WHILE PLAYERS.COUNT < N` became a normal gate to write. The server-side `NetState` includes the host (slot 0) from startup. The client-side `NetState` only learned about other players via `net_player_joined` broadcasts, and the host never broadcasts a join for itself (no audience at startup). With `MAX PLAYERS 3` and 2 connected human players, the host saw `PLAYERS.COUNT = 3` but the client saw `2`. A `WHILE PLAYERS.COUNT < 3` gate cleared on the host but hung the client forever.

Fix: when a client connects, the server now pushes a synthetic snapshot listing every currently-connected player (host slot 0 + every player slot) plus the current `host_data` snapshot, right after the late-join sprite snapshot. `PLAYERS.COUNT` is correct on the first read after connect, the host slot is known, and any `HOST DATA` values written before this client joined are immediately readable.

**Practical impact:** `WHILE (PLAYERS.COUNT < 3 AND GAMECONTEXT$ <> "SINGLE")` lock-step gates work on remote-mode CrashPlayer clients without authors needing to add an `AND GAMECONTEXT$ <> "CLIENT"` hack. The standard "wait for everyone, then start" pattern works the way authors expect.

### Per-tick sprite layer is now authoritative across the wire

Sprite layer changes used to be sticky on the authoring side but didn't ride through to other clients on per-tick state updates. A late-joining client could see the original layer assignment forever, even after the host had moved the sprite up or down a layer. The per-tick sync now carries layer, late-join snapshots include it, and behaviors that animate layer (visual depth changes, parallax handoffs) replicate cleanly.

---

## Diagnostics

### GUI-launched CrashPlayer surfaces fatal errors instead of disappearing

CrashPlayer launched via the `crashnet://` URL handler, a `.crashnet` double-click, or a `.app` bundle has no visible stderr — anything that fails during the descriptor / cart / auth handshake used to print to a system log nobody could see and the app silently exited.

Now a native modal dialog pops up before exit. CLI invocations still get the usual stderr they're used to. Failure sites covered: descriptor flow errors, cart open / extract / temp-dir failures, "not a directory or crashcart", chdir failures, library-spawn failures. Each message names a likely cause and a concrete next action ("re-open the link from the server's login page", "free disk space", "reinstall CrashPlayer").

### Server-side runtime errors are now properly humanized

Runtime errors raised on a host or player-server interpreter (resource-load-on-server guards, division-by-zero, undefined-function, schema-violation on PLAYER DATA writes, etc.) used to surface as a single bare line:

```
[Host] Error: Resource loads are not allowed on the server side in multiplayer.
```

The error itself was already humanized — keyword, file:line:column, friendly Markdown, and the actionable hint were all assembled — but the headless-host log handler printed only the message, dropping the location and hint. Operators couldn't tell which line of their `.bas` tripped it.

Now the full breakdown lands in the log:

```
[Host] LOADWAV error at main.bas:9:1: Resource loads are not allowed on the server side in multiplayer.
[Host]   hint: Move this call into an `IF ME.ISCLIENT THEN ... END IF` block. Only the
        client renderer can load textures, fonts, audio, or video; server-side code should
        reference resources by name once clients have loaded them.
```

Every server-side runtime error path benefits — not just the resource-load guard.

### Added: docs — *Grokking Sprite Positions and Scrolling*

New developer doc walks the whole sprite-position pipeline end-to-end: behavior engine (world coordinates only) → pattern engine (just orchestration) → broadcast → server-side per-recipient transform (in multiplayer) → renderer (subtracts layer scroll). Calls out the double-apply trap when `SET SCROLL` runs in both server-side and client-side contexts, the `IF ME.ISSERVER AND NOT ME.ISHOST` gate that fixes it, and where to add new camera math (server-side viewport culling, smooth-follow, layer-wrap collision) without breaking the "behaviors don't know about cameras" invariant. Voiced for a future-you-who-forgot read; written so it reads cold without paging in any other doc.

---

## What didn't change

Single-player behavior, the wire format, the Arcade-passthrough auth path, existing `.crashcart` files, and the WASM player flow all stay where they were. Default JWT iss/aud values still match Arcade's (`crashbasic-arcade` / `crashbasic-client`) so existing Arcade-issued tokens validate without operator action — local authority mode is opt-in and changes the iss/aud to `crashserver-local` / `crashserver-client` so the two can never be confused for each other. SQLite-only deployments stay on the same binary size; the Postgres feature is opt-in.
