# Crash BASIC 1.29.0 Release Notes

*~written by Crash-tan~*

## THE "WITH" RELEASE

1.29.0 introduces `WITH ... END WITH` as a generic block-scoped context primitive, then uses it to plug the biggest remaining hole in CrashNet multiplayer: **room-aware tilemap replication**. The same primitive doubles as a quality-of-life win for single-player code (block-scoped layer / font / color), so this isn't a multiplayer-only release even though that's what motivated it.

It also turned into the **operational hardening release** along the way — CrashNet picked up bandwidth caps, admin telemetry, real loopback-aware metering, an actually-honored `TICK RATE`, and a renderer LERP path that smooths everything out across the wire. The release pipeline gained CDN-pruning tooling, signing-key rotation for upload, and a few quiet-but-load-bearing manifest fixes that were silently dropping Android + iOS from every signed manifest since the auto-update flow shipped.

---

## `WITH` blocks: ambient context, no temp-variable juggling

The new primitive replaces the "save current, set new, do work, restore" boilerplate that any context-changing code path used to need.

```basic
' Before
oldLayer = CURRENT.LAYER
SET LAYER 3
CLS 0
PRINT "Score: " + STR$(score)
SET LAYER oldLayer

' After
WITH LAYER 3
    CLS 0
    PRINT "Score: " + STR$(score)
END WITH
```

The block pushes a context frame on entry and pops it on exit — including when you `EXIT FOR`/`EXIT DO`/`RETURN` mid-block. Statements inside resolve their ambient context from the stack; statements outside fall back to whatever default applies.

### Four variants in 1.29.0

The first two are *ambient context* — they push a frame on a runtime stack that statements inside the block read from. The last two are *compile-time hoisting* — the block body is lifted into a synthetic top-level SUB at parse time and the WITH site becomes an `EVENT CLIENT*` fire.

**`WITH LAYER n`** — block-scoped equivalent of `SET LAYER n`. Pure ergonomic win, no replication semantics. Pushes `n` as the current draw layer for the block, restores the previous on exit (including when the block exits via `EXIT FOR/DO`, `RETURN`, or a halting error). Negative values clamp to 0, mirroring `SET LAYER`'s tolerance. Stacking `WITH LAYER` blocks shadows correctly — the inner block's exit restores the outer block's layer, not the baseline.

**`WITH ROOM "name"`** — every tilemap, sprite, and layer registration inside the block gets tagged with that room. Outgoing broadcasts filter to clients in that room. Late-join replay walks per-room snapshots. **Single-player gets auto-level-switching for free** — `WITH ROOM "level1" ... END WITH` followed by `SET ROOM "level1"` mounts everything tagged `level1` and tears down the rest, with no manual destroy/recreate boilerplate.

**`WITH PLAYER n SEND (args)`** — ship the block body to player `n`'s server-side PLAYER-SERVER interpreter (slot-N runner). Server-authoritative state changes — `SET ROOM`, mutating `PLAYERS(n).<field>`, calling `HOST.<field>`, etc. — are legal inside because player-server is the right interpreter to make them "as that player." The block is hoisted at compile time into a synthetic top-level SUB with the listed `args` as BYVAL parameters; auto-registered as an event handler via `CALL EVENT`; the WITH site becomes an `EVENT SERVER n "<anon>" (args)` fire. Same `.crash` runs on every interpreter, so the synthetic SUB is already in every interpreter's function registry by the time the event lands.

For client-side rendering dispatch (overlays, score popups, per-player effects), use `WITH CLIENT` below — it routes contextually and never targets server-side interpreters. If you specifically want to ship to one player's RENDERER (not their player-server), use raw `EVENT CLIENT n` for now.

**`WITH CLIENT SEND (args)`** — same shape as `WITH PLAYER` but with **contextual routing** that adapts to where you call it from:

- From the host: broadcasts to every connected client (`EVENT CLIENTS` under the hood). Game-start announcements, lobby chats, anything every player should see at once.
- From a player-server: fires on that player's own client only (`EVENT CLIENT ME.SLOT`). Lets shared code do "tell the relevant client" without `IF ME.ISHOST` gating.
- From a multiplayer local-client: fatal runtime error (matches the existing `EVENT CLIENT*` authority rules — clients don't have standing to fire client-side handlers over the wire).
- In single-player: collapses to in-process dispatch, same as other cross-the-wire events.

The `SEND (args)` clause is optional — `WITH PLAYER 2` and `WITH CLIENT` with no args parse fine and emit zero-argument synthetic SUBs.

### Composing them

```basic
IF ME.ISHOST THEN
    WITH ROOM "lobby"
        TILEMAP "lobbyMap" ... END TILEMAP
        CREATE MAP "lobbyMap" AS LAYER 1
    END WITH

    ' Per-player victory message — hoisted to a synthetic SUB on
    ' both host and clients; the host fires the event to slot 2.
    winner_name$ = PLAYERS(2).NAME$
    WITH PLAYER 2 SEND (winner_name$, score)
        PRINT winner_name$ + " wins with " + STR$(score) + "!"
    END WITH

    ' Game-start announcement — same hoisting, broadcast target.
    WITH CLIENT SEND (countdown)
        PRINT "Starting in " + STR$(countdown) + "..."
    END WITH
END IF
```

LAYER/ROOM compose with one another (orthogonal scoping dimensions). Same-dimension nesting shadows (inner wins, outer restored on inner pop). PLAYER/CLIENT can't be nested inside one another usefully — they're one-shot fires, not ambient contexts — but they can sit alongside LAYER/ROOM blocks in any order.

### `SYNC` variants — two-way coordination

`WITH PLAYER n SEND` and `WITH CLIENT SEND` are fire-and-forget: the caller continues past `END WITH` immediately while the body runs on the remote interpreter in its own thread. Most of the time that's what you want — game-state events shouldn't serialize on RPC latency.

When you do need ordering — "run this player-server setup, *then* announce the join" — 1.29.0 adds `SYNC`:

```basic
WITH PLAYER n SYNC (args)
    ' body runs on player-server n
END WITH
' execution resumes here ONLY after the body finished
```

Same shape on `WITH CLIENT SYNC`. The caller's thread yield-polls a flag on the originating NetState until the remote dispatcher acks — the BASIC `YIELD` semantic stays correct (pattern engine + bridge tick keep running while the caller waits).

**Ack-only contract, no return values.** SUBs don't have return values; the SYNC variant matches that. The body just signals "done." If it halted with a runtime error mid-way, the operator sees that error on the remote side (stderr / runtime-error dialog) — the caller just learns "we're done here." Disconnect during the wait unblocks the caller via the existing kill-signal path.

**Authority rules:**

| Caller | `WITH PLAYER n SYNC` | `WITH CLIENT SYNC` |
|---|---|---|
| Host | Legal | **Fatal** — broadcast-then-wait-all has no coherent ack |
| Player-server (own slot) | **Fatal** — would deadlock pattern-tick | Legal (targets own client) |
| Local-client | Fatal | Fatal |

The "player-server self-target SYNC is fatal" rule is the one new restriction beyond SEND's. A player-server interpreter calling `WITH PLAYER ME.SLOT SYNC` would block the same pattern-tick thread that's supposed to dispatch its own body — the wait would never resolve. `SEND` doesn't have this problem; use it for own-slot async dispatch. Cross-slot SYNC from a player-server should go through `EVENT HOST` + a `CALL HOST` handler that does the SYNC on the host's behalf.

`WITH HOST` (any variant) deliberately doesn't exist. The downward direction — client or player-server forcing code execution on the host — would break the server-authoritative model. Use `EVENT HOST` from a client, or `EVENT SERVERS` from the host, for the legitimate request/respond patterns.

Wire shape:

* `WITH PLAYER n SYNC` is fully in-process on the server. The host's pending-sync map (on the shared `NetState`) holds a flag the player-server's dispatcher flips after the body completes. No new wire frames.
* `WITH CLIENT SYNC` adds two wire bits: the `client_signal` JSON gains an optional `reply_token` field, and a new `net_client_signal_ack` inbound JSON message carries the token back. The server's reader task routes acks directly to `NetState::deliver_sync_reply` without going through the bridge tick.

### What `WITH PLAYER` / `WITH CLIENT` compile to

Behind the scenes the AST builder rewrites:

```basic
WITH PLAYER 2 SEND (winner_name$, score)
    PRINT winner_name$ + " wins with " + STR$(score) + "!"
END WITH
```

…into three statements: a synthetic SUB lifted to program top level, its event-handler registration, and the WITH site itself replaced by an `EVENT CLIENT` fire:

```basic
SUB __with_anon_0(winner_name$, score)
    PRINT winner_name$ + " wins with " + STR$(score) + "!"
END SUB
CALL EVENT __with_anon_0(0) AS "__with_anon_0"
' ... user code, at the original WITH site:
EVENT CLIENT 2 "__with_anon_0" (winner_name$, score)
```

The SUB definition + registration are prepended to the program's top-level statements so they execute before any user code runs. Both host and clients run the same `.crash`, so they all register the handler at startup — by the time the host's event fires, the receiving client already has the SUB in its function registry.

Args are BYVAL: the values are evaluated in the host's current scope at the WITH site and shipped as event arguments. The block can't mutate host state remotely — that'd require a sync-back path which intentionally doesn't exist. If you want to mutate server state, write to `PLAYERS(n).field` directly.

---

## Room-aware tilemap replication

The CrashNet hybrid model treats sprites + behaviors as room-scoped — late-joiners get a snapshot replay of just their room, room transitions tear down the old room and replay the new. Tilemaps, until now, weren't room-aware: the host's `CREATE MAP` / `UPDATE MAP` broadcast to every connected client unconditionally, and a player who joined ten seconds after the host's `CREATE MAP "level1"` saw an empty level (no snapshot replay path).

1.29.0 closes that gap by tagging every tilemap registration with a room (via the ambient `WITH ROOM` context, or `_global` as a sentinel for shared content), routing broadcasts through the room filter, and adding tilemaps to the late-join + room-change snapshot replay paths.

```basic
' Host sets up four levels' worth of tilemap data, room-tagged
IF ME.ISHOST THEN
    FOR i = 1 TO 4
        WITH ROOM "level" + STR$(i)
            TILEMAP "lvl" + STR$(i) ... END TILEMAP
            CREATE MAP "lvl" + STR$(i) AS LAYER 1
        END WITH
    NEXT i
END IF
```

Players in `"level2"` only ever see `"level2"`'s tilemap; switching rooms swaps the whole map automatically. Late-joiners get the full picture of their room on connection.

### `_global` sentinel

A reserved room name — content tagged `_global` (host's default outside any `WITH ROOM` block) is visible regardless of player room. Use for HUDs, lobby music, system overlays, anything cross-cutting.

### Map-traversal builtins are room-aware too

`MAPMETA$`, `FOR MAPDATA`, and `AStar` resolve the ambient room from the surrounding `WITH ROOM` context (or the caller's current `ME.ROOM` outside any block). A `SUB CheckCollision(x, y)` works identically in single-player and multiplayer with no per-context branching — the ambient room handles it.

---

## Migration notes

- **No breaking changes for existing programs.** Programs that don't use `WITH` blocks behave identically; on the host, statements outside any `WITH ROOM` block tag their commands `_global` (visible to everyone, current behavior).
- **Rooms-aware multiplayer code:** if you were manually broadcasting tilemap state via shared arrays + `IF ME.ROOM = "..."` branching, you can simplify by wrapping your room setup in `WITH ROOM "..."` and letting the runtime do the filtering.
- **Single-player code:** no required changes. `WITH LAYER` is a drop-in replacement for `SET LAYER` boilerplate when you'd rather not save/restore.

---

## Bug fixes

*(rolling list — fill in as slices land)*

### `SCREEN` is now idempotent on repeat with the same dimensions

`SCREEN w, h` previously destroyed every layer, tilemap, animation, and shader transition on every invocation, even when called with the same dimensions as the already-initialized screen. That made it a foot-gun in multiplayer / remote-client setups: the joining player's local CrashPlayer runs the program (and thus a `SCREEN` line) concurrently with the host's `WITH ROOM` / `SET ROOM` replay that ships `CreateLayer` + `CreateTilemap` for the joiner's starting room. If the local `SCREEN` won the race, it nuked the just-painted map and the joiner saw an empty room.

1.29.0+: a `SCREEN` call whose logical `(width, height)` already matches the initialized screen updates the window (title, scale, fullscreen, viewport recompute) but leaves layer / tilemap / animation / shader state intact. A `SCREEN` call with *different* dimensions still resets everything — the existing render targets are the wrong size, that's the right move.

If you were leaning on a second `SCREEN` mid-program as a "clear everything" shortcut, switch to `BEGIN SCENE` / `END SCENE` (transactional, restores cleanly) or explicit `CLS` + `DESTROY LAYER` (precise). The retention upgrade above makes `BEGIN SCENE` the right tool for "fresh slate then come back to what I had" anyway.

---

## Scene retention: instant `END SCENE` restores

`BEGIN SCENE ... END SCENE` got a meaningful upgrade. Before 1.29.0, scene push only saved layer *metadata* (and CPU-side tile arrays); on pop, the renderer destroyed every layer, recreated GPU textures from scratch, and re-baked every tilemap from its tile data. For a level-transition scene that returns to a busy overworld, that was a visible re-render cliff — a few frames where the screen redrew itself layer-by-layer.

1.29.0 flips the default to **retention mode**: scene push *moves* the live GPU texture handles into the scene snapshot (so they sit in VRAM untouched for the scene's duration) and gives the scene body a blank slate. On `END SCENE`, the saved handles move back. Restoration is a single-frame reference swap — no destroy, no recreate, no re-bake, no flicker.

```basic
BEGIN SCENE "battle"        ' pre-scene layers retained intact
    CREATE LAYER 1, 320, 240
    CREATE MAP "battlefield" AS LAYER 1
    ' ... entire battle scene plays out ...
END SCENE                   ' pre-scene state back, instantly
```

The scene body starts with no layers — that's a behavior change from pre-1.29.0, where the scene inherited the live state. Most scenes start with a CLS or fresh layer setup anyway; programs that explicitly wanted the old inherit-and-overwrite semantics can opt in via:

```basic
BEGIN SCENE FAST "menu"     ' lower VRAM, pre-1.29.0 behavior
    CLS 0
    ' draw menu over existing state
END SCENE                   ' destroy + rebuild + re-bake (visible)
```

`FAST` mode trades restore latency for VRAM — it stores metadata only, the scene body inherits and overwrites live state, and pop re-creates GPU resources from scratch. Use it for memory-constrained programs, deeply-nested scene stacks, or one-way transitions where you'll never need the saved state restored instantly.

### What's retained / restored

- **Layer textures (front + back buffers)** — handles move into the snapshot at push, swap back on pop. Zero re-render.
- **Tilemap GPU textures** — same. No re-baking from CPU tile arrays on pop.
- **Layer animations** — animation metadata + frame texture handles retained alongside their owning layer.
- **Logical screen dims** (`screen_width` / `screen_height`) — captured at push, restored on pop. If `SCREEN w, h` runs during the scene, post-`END SCENE` you're back to the pre-scene resolution.
- **`NetState.room_tilemaps`** (host-side) — pushed/popped in lockstep with the renderer snapshot. Late-joiners during a scene see the scene's tilemap state; post-pop late-joiners see the pre-scene state. Multiplayer-coherent.
- **Palette, sprites, behaviors, patterns** — already saved/restored by the existing `BEGIN SCENE` machinery; unchanged in 1.29.0.

### Tile-array sync caveat (option B)

After `END SCENE`, the renderer shows the restored (pre-scene) tilemap state. But if BASIC arrays bound to tilemaps were mutated during the scene, those arrays still hold their during-scene values. Calling `UPDATE MAP "name"` after `END SCENE` resyncs the renderer to the array's current state. This is intentional — scene retention is a *render checkpoint*, not a full state checkpoint. If you want full sync, mutate the arrays back yourself (or `UPDATE MAP` explicitly).

### VRAM budget

Rough rule of thumb for retention mode: each scene level retains ~`layer_count × screen_w × screen_h × 4 bytes × 2 (front+back)` plus tilemap textures. For a 1280×720 game with 8 layers, that's ~58MB per scene level. Mostly fine on desktop, tighter on mobile. Use `BEGIN SCENE FAST` if you're nesting deeply on memory-constrained hardware.

## Partial indexing of higher-dimensional arrays

Indexing an array with **fewer indices than its rank** now returns a shared-storage view of the remaining dimensions. `Rooms(0)` on a 3D array yields a 2D view; `Rooms(0)(3, 2) = 4` round-trips to `Rooms(0, 3, 2) = 4` because the view aliases the parent's storage. Views can be assigned to variables, nested (`grid(1)(2)` → 1D view), iterated by outer dim, and passed around like any other array. They can't be resized — `PUSH` / `POP` / `DEQUEUE` on a view is a no-op since the parent's outer-dim shape is fixed. See `docs/types.md` → **ARRAY** → *Partial Indexing* for the full story.

Combined with the next bullet, this gives you the room-atlas pattern with one global instead of N:

```basic
DIM rooms(0 TO 9, 0 TO 99, 0 TO 59)
TILEMAP "lvl3"
    TILES 0 FROM rooms(3)        ' bind the 2D slice for room 3
END TILEMAP
```

### TILEMAP `FROM` now accepts any expression

Bare-identifier `FROM tiles` keeps the previous live-rebind-by-name behavior for backward compat. Expression-form `FROM rooms(level)` evaluates **once at registration time** and snapshots the resulting `ArrayRef`; mutations through the underlying storage stay live (write to `rooms(3, x, y)`, call `UPDATE MAP "lvl3"`, GPU catches up), but reassigning the root variable does not — re-execute the TILEMAP DEF in that case.

`INTO` accepts the same two shapes: a bare identifier auto-`DIM`s a fresh global from BASEMAP (the legacy form); a slice expression like `INTO Rooms(0)` writes BASEMAP tile data into the named slice of an existing higher-D array.

---

## Other multiplayer / solo polish

- **Room-bound content is now invisible until you enter that room** on solo / local-render too — multiplayer hosts already filtered this per-recipient, solo was the holdout. Sprites and tilemaps created inside `WITH ROOM "x"` sit in the registry until `SET ROOM "x"` brings them on screen. Global content (no `SYNC` / outside any `WITH ROOM`) stays always-visible. `SET SPRITE n SYNC ("new")` reconciles visibility on the fly too — a sprite that was visible and is now bound to a different room gets disabled; one that wasn't visible and now matches gets enabled and state-shipped.

- **`GAMECONTEXT$` no longer mis-reports `"CLIENT"` in solo (especially WASM).** Solo runs now attach an in-process `NetState`, but the getter still expected the absence of NetState as the SINGLE signal — so it fell through to the `!is_server` arm and returned `"CLIENT"` on every solo launch. Fixed.

- **Sprites auto-tag to the ambient `WITH ROOM`** — same ergonomic as tilemap mounts. A sprite first created inside `WITH ROOM "x"` without an explicit `SYNC` / `NOSYNC` modifier picks up `"x"` as its room. Explicit modifiers still win.

- **Solo `SET ROOM` now swaps sprites, not just tilemaps.** Mirrors the multiplayer replay phases — disable sprites tagged for the old room, enable + ship state for sprites tagged for the new room. Tilemaps already worked; sprites just got the same treatment.

---

## CrashNet: `TICK RATE n` finally controls wire traffic

The `CRASHNET TICK RATE n` setting from your BASIC block has been parsed, stored, and surfaced via `CRASHNET.TICKRATE` since multiplayer shipped. It was also being **completely ignored by the broadcast pipeline** — the host's `SpriteStateUpdate` broadcasts fired off the pattern engine's hardcoded 60 Hz tick regardless of what your `CRASHNET` block said. Result: a 4-player lobby with three idle slots burned ~67 MB/hr of outbound bandwidth no matter how chill the game logic was.

1.29.0 wires the broadcast emit to `RoomConfig.tick_rate` on the multiplayer host. Behaviors still tick at 60 Hz on the server (game logic stays responsive), but the network ship is gated to whatever you set. Default of 20 Hz drops the lobby scenario above to ~22 MB/hr; bump to `TICK RATE 30` if your game needs fast-twitch sync, drop to `TICK RATE 10` if it's turn-based / chat-driven and you want more headroom.

```basic
CRASHNET
    TICK RATE 30      ' explicit; previous default of 20 still works
    ...
END CRASHNET
```

Single-player keeps emitting every tick (no wire, no benefit to throttling). Player-server and client-local interpreters likewise — only the host's broadcast was wasteful at 60 Hz.

---

## Smooth sprite motion between network ticks (LERP)

The bandwidth drop above would normally come with a tradeoff: clients see the sprite world update every 50 ms instead of every 16, which on its own reads as judder — every move chunks visibly to its next position.

1.29.0 adds per-sprite linear interpolation in the renderer that fills the gap between updates. Position, alpha, scale, and rotation slide smoothly from each received state to the next over the measured arrival interval; rotation crosses zero correctly (so a sprite spinning from 359° → 1° doesn't unwind through 180°). Discrete fields (animation frame, texture, visibility, layer, flip) still snap on the same update because interpolating those wouldn't make sense.

The interpolation auto-tunes to the actual arrival cadence and only engages when the gap is wide enough that snapping would visibly stutter — network multiplayer needs it; high-rate sources like single-player at 60 Hz keep their direct write-through, identical to pre-1.29 behavior. As a side effect, the renderer also absorbs occasional per-frame jitter on slow / variable hardware: when render frames are spaced unevenly (tab regaining focus, GC pause, thermal throttle), the sprite glides through the hiccup instead of teleporting to its latest position.

Game logic is unaffected — `SPRITEX()` / `SPRITEY()` still read authoritative server-side state, the interpolation only changes what the renderer draws.

---

## CrashNet: bandwidth caps now have a default

The cap mechanism has been in `crashnet-server` since multiplayer hardening — `CRASHNET_BANDWIDTH_CAP_GB` env var, rolling-window snapshot table, refuse-new-connections enforcement when exceeded. What was missing: any actual default, so every per-game deployment ran uncapped until an operator manually set one. A runaway broadcast loop or hostile reconnect spam would just keep going.

1.29.0 makes the Arcade deployment service ship a default cap with every deploy:

```
2 GB outbound over a rolling 30-day window
```

Generous enough that most games never bump it (the post-throttle lobby scenario above runs ~16 GB/year), strict enough that a hot loop can't rack up unbounded bytes before anyone notices. Arcade owns the dial; per-game admins can't quietly raise their own ceiling. If your game needs more headroom, contact Arcade.

---

## Stamped CrashCarts can ship to CrashNet

Stamped builds (the commercial-tier exports from `crashcart stamp`) used to be locked out of Arcade entirely on the assumption that they belonged in the standalone-distribution lane — burn-to-disk, sell on your own storefront, don't run through the Arcade pipeline. With CrashNet shipping as the multiplayer host, that distinction stopped making sense: a Studio-tier multiplayer game still needs to live somewhere, and the natural place is the same Arcade upload + CrashNet deploy flow every other game uses.

1.29.0 accepts stamped CrashCarts on upload and preserves the stamp end-to-end. OpenAI moderation screens stamped builds identically to unstamped ones; the stamp survives bit-identical through storage and onto the CrashNet droplet, so commercial-tier features (no splash, removable splash, custom branding) light up correctly when players connect.

Practically: if you've been holding off uploading a stamped build because the upload page rejected it with a confusing key-mismatch message, you can stop holding off.

---

## One-click signing-key rotation on upload

The most common cause of legitimate signing-key mismatches isn't an attacker — it's the developer reinstalling the IDE on a new machine and getting fresh keys. Pre-1.29 the only recovery was a four-step detour through Account Settings to clear the stored key, re-export, re-upload.

Now: when the upload (or re-upload) detects that the cart was signed with a different key than the one on file, the browser prompts:

> *This cart was signed with a different key than the one stored for your account. If you changed your signing setup (new machine, fresh IDE, lost backup), click OK to replace the stored key and re-upload. Cancel if this upload wasn't yours.*

OK rotates the stored key to the new one and proceeds with the upload normally. The same auth + ownership gates protect the rotate path — only the authenticated game owner can trigger it.

---

## Auto-update manifests now include Android + iOS

Two long-standing leaks in the release-manifest pipeline that made Android and iOS effectively invisible to the auto-updater and CrashPlayer's installer dialogs: the manifest schema had no field for `.apk` (so a correctly-uploaded APK was silently dropped on every manifest re-sign), and the manifest rebuild path had no iOS probe (so even versions with an iOS GameTemplate on disk had no `ios` entry in their manifest at all).

Both fixed in 1.29.0. The next manifest rebuild on any version with those assets on disk produces a real reference to them, and CrashPlayer's auto-update flow on Android + the GameTemplate download flow on iOS finally see the platforms they always thought they had.

---

## Map builtins are room-aware on player-servers too

`MAPWIDTH`, `MAPHEIGHT`, `MAPTILE`, **`MAPMETA$`**, and **`FOR MAPDATA`** all fall back to the host's per-room snapshot store when the local renderer cache is empty. That's the path a player-server interpreter goes down: no renderer, no inbound `CreateTilemap`, so before 1.29 these builtins all returned 0 / "" / empty even though the host had registered the tilemap (+ its metadata) for the player's current room.

Now they all resolve through the ambient room — `MAPMETA$(x, y)` on a player-server returns the right metadata for that player's `ME.ROOM`, same as the host would compute it. Builtins behave identically across host, player-server, and client interpreters, no `IF ME.ISHOST` branching required.

---

## Heads-up footguns (not bugs, but worth knowing)

- **`UPDATE MAP` outside its `WITH ROOM` block** broadcasts unfiltered. If `CREATE MAP` ran inside `WITH ROOM "lobby"` but `UPDATE MAP "lobbyMap"` runs at top level, the update goes to every connected client (including ones in other rooms) while the original mount was lobby-scoped. A future release may auto-resolve to the room the tilemap was originally mounted in; for now, wrap your UPDATE MAP in the same `WITH ROOM` block.

- **`WITH PLAYER` args are BYVAL.** Mutating a captured argument inside the synthetic SUB body doesn't flow back to the host — args are evaluated at the WITH site, shipped as event arguments, and the synthetic SUB only sees its own arg-bound scope. If you need to mutate server state, write to `PLAYERS(n).field` directly.
