# Crash BASIC 1.28.0 Release Notes

*~written by Crash-tan~*

## THE "ARRAYS ARE THE TRUTH" RELEASE

Programmatic tilemaps land in 1.28.0 — write maps from BASIC code, mutate them like any other array, then push to the GPU when ready. The rendering pipeline that used to require a `.map.json` round-trip through the editor now accepts your live tile arrays directly. A* / line-of-sight / flood-fill all work against the same array the renderer is reading, so logic and rendering finally share a single source of truth.

This is a minor bump that builds on the 1.27 development cycle — every multiplayer / CrashNet feature added in that cycle (rooms, local authority, PLAYER DATA / HOST DATA replication, the dashboard polish, kick-on-crash + retry, the deploy pack, etc.) ships here too. See [`RELEASE_NOTES_1.27.0.md`](RELEASE_NOTES_1.27.0.md) for that backlog. 1.28.0 adds programmatic tilemaps, the GUARD overhaul, the multiplayer splash fix, and the EVENT solo-collapse on top of all of it.

> **Heads up:** like 1.27.0, 1.28.0 still isn't quite end-user battle-ready — there are rough edges multiplayer needs real-world traffic to expose, and the new programmatic-tilemap path hasn't been hammered on yet. But it's getting close. If you've been waiting for "real enough to start a small server with friends + procedurally-generated levels", this is the release where that becomes a reasonable bet.

---

## Programmatic tilemaps: TILEMAP DEF, named CREATE MAP, UPDATE MAP

### The headline feature

The `.map.json` file you drew in the editor used to be the only way to feed `CREATE MAP`. 1.28.0 adds a `TILEMAP` block that defines a tilemap from your own BASIC arrays, plus a named form of `CREATE MAP` to render it, plus `UPDATE MAP` to push array mutations back to the GPU.

```basic
DIM tiles(0 TO 99, 0 TO 59) AS INTEGER

TILEMAP "level1"
    TILESET "grass.png"
    TILESIZE 32, 32
    SIZE 100, 60
    TILES 0 FROM tiles
END TILEMAP

CREATE MAP "level1" AS LAYER 0
```

That's the smallest valid case. Multi-layer + metadata + a `BASEMAP` starting state work the same way:

```basic
TILEMAP "shop"
    BASEMAP "shop-base.map.json"        ' editor-drawn floor + walls
    TILES 0 AS "floor" INTO floorTiles  ' grab the editor layer as a global
    TILES 1 AS "walls" INTO wallTiles
    TILES 2 AS "decor" FROM proceduralDecor   ' procedural overlay
    META  "doors"      INTO doorGrid$   ' grab the editor's door markers
END TILEMAP

CREATE MAP "shop" LAYER "floor" AS LAYER 0
CREATE MAP "shop" LAYER "walls" AS LAYER 1
CREATE MAP "shop" LAYER "decor" AS LAYER 2
```

### FROM vs INTO

| Direction | Behavior |
|---|---|
| `FROM <var>` | Bind an existing globally-scoped array — the user owns the data, the TILEMAP just references it. Array must already be DIM'd at global scope. |
| `INTO <var>` | Auto-DIM a new global of the right shape, populate it from the BASEMAP file. Requires `BASEMAP` in the same block. After it runs, `<var>` is a normal global the rest of the program can read + mutate. |

Both produce the same kind of binding — an entry in the registry pointing at a globally-scoped variable. The two-way syntax means you can mix editor-supplied layers with procedural ones in the same `TILEMAP` block.

### UPDATE MAP — the explicit slow path

Mutating a tilemap is just direct array assignment:

```basic
tiles(x, y) = newTileIdx       ' single tile
spawnGrid$(x, y) = "P"         ' single metadata cell

' Or a region
FOR y = 5 TO 10 : FOR x = 5 TO 10
    tiles(x, y) = waterTile
NEXT : NEXT

UPDATE MAP "level1"             ' push to GPU
UPDATE MAP "level1" LAYER "decor"   ' refresh just one tile layer
```

⚠ `UPDATE MAP` triggers a tilemap GPU reload. The cost is small (one int update per cell) but real — don't call it per-frame. The mental model: your arrays are always the truth, the GPU is a stale-until-told cache, `UPDATE MAP` is the cache flush.

### Pathfinding-friendly by design

Because the tile data lives in normal BASIC arrays, the same array the renderer is reading is the one your AI / pathfinding / line-of-sight code mutates:

```basic
DIM path AS COORDINATE
path = AStar(tiles, startX, startY, endX, endY, BLOCKED_TILES)

' AI decided to open a door — mutate the array, push to GPU
tiles(doorX, doorY) = OPEN_DOOR_TILE
UPDATE MAP "level1"
```

No serialization layer between "logic" and "render." No double-storage. One source of truth.

### Level-loader pattern

`TILEMAP` block bindings always go to **global scope**, regardless of where the block executes. Run a `TILEMAP DEF` inside a SUB and the bindings + INTO globals it creates persist after the SUB returns. Re-execute the same `TILEMAP` name and it replaces the registry entry + regenerates the INTO globals — clean reload semantics for level loading:

```basic
SUB LoadLevel(n)
    TILEMAP "current"
        BASEMAP "level" + STR$(n) + ".map.json"
        TILES 0 AS "bg" INTO levelBg
        META  "doors"   INTO doorGrid$
    END TILEMAP
    CREATE MAP "current" AS LAYER 0
END SUB

CALL LoadLevel(1)         ' levelBg, doorGrid$ now hold level 1
' ...gameplay...
CALL LoadLevel(2)         ' levelBg, doorGrid$ replaced; CREATE MAP refreshes the GPU
```

### Three forms of CREATE MAP

The original `CREATE MAP("file.map.json")` form keeps working unchanged. 1.28.0 adds two more:

```basic
' (1) File-loaded — original, still here:
CREATE MAP("level1.map.json") AS LAYER 0

' (2) Named — renders a TILEMAP DEF:
CREATE MAP "level1" AS LAYER 0
CREATE MAP "level1" LAYER "foreground" AS LAYER 2

' (3) Inline — anonymous, throw-away from a BASIC array:
CREATE MAP FROM tileArr TILESET "grass.png" TILESIZE 32 AS LAYER 0
```

`MAPMETA$()` and `FOR MAPDATA` work identically against all three forms.

### Validation

Parse-time and exec-time errors halt loud. The full list is in the `TILEMAP` doc entry, but the key ones:

- **AS "name" required** when a TILEMAP block has 2+ tile layers.
- **Tile-layer indexes** must be unique, contiguous from 0, non-negative.
- **Tile-layer names** unique within the block. **META names** unique within the block.
- **INTO requires BASEMAP** in the same block.
- **FROM arrays must be globally scoped** — declared outside any SUB. (They're auto-validated at exec time; mismatched-shape FROM arrays also halt.)
- **Without BASEMAP**, all of `TILESET`, `TILESIZE`, `SIZE` are required.

---

## GUARD overhaul: ONCE lock + combinable contexts + SINGLE = solo

The old `GUARD` modifier had two pain points:

1. `SINGLE` was overloaded — it named the call-once invocation lock AND collided mentally with single-player mode (`GAMECONTEXT$ = "SINGLE"`). Authors expected `GUARD(SINGLE)` to mean "fires only in solo," but it actually meant "drop overlapping invocations."
2. Context flags (`HOST`/`PLAYER`/`CLIENT`) were mutually exclusive, so you couldn't say "fire in solo OR on the host" — and the old solo short-circuit silently fired every context flag in single-player, which meant a Free-tier solo flow could accidentally trigger host-only logic.

1.28.0 splits the modifier into two orthogonal kinds:

| Kind | Flags | Meaning |
|---|---|---|
| Context (combinable, OR-matched) | `SINGLE` | Fires only in solo (single-player) launches |
| Context | `HOST` | Fires only when `ME.ISHOST` |
| Context | `PLAYER` | Fires only when `ME.ISSERVER AND NOT ME.ISHOST` |
| Context | `CLIENT` | Fires only when `ME.ISCLIENT` |
| Lock (orthogonal) | `ONCE` | Skip overlapping invocations (the old `SINGLE`) |

Context flags are now combinable with **OR semantics** — list any subset, the handler fires when the current interpreter matches any listed context. `ONCE` is orthogonal — combine with any context set or stand alone.

### Strict context matching, no solo short-circuit

`GUARD(HOST)` does NOT fire in solo anymore. Solo is its own context (the new `SINGLE` flag). The intended pattern:

```basic
' Fires solo + multiplayer host (canonical "authoritative game logic")
GUARD(HOST, SINGLE)

' Fires solo + every multiplayer client (canonical "render/UI")
GUARD(CLIENT, SINGLE)

' Fires only solo
GUARD(SINGLE)

' Fires solo + all server-side (host or player-server)
GUARD(HOST, PLAYER, SINGLE)

' No-op invocation lock, no context restriction
GUARD(ONCE) FAILS
```

This unblocks the "write a complete game with context-targeted GUARDs throughout, deploy to either solo or multiplayer, the same `.bas` runs by adding `SINGLE` wherever the handler should also fire solo" pattern.

### Migration

If you used `GUARD(SINGLE)` for the call-once lock, rename to `GUARD(ONCE)`. The new `SINGLE` flag means "solo only." Combinations like `GUARD(HOST, SINGLE)` are now legal and useful — the old "context flags are mutually exclusive" error is gone.

---

## EVENT targeting collapses to local in solo

The new `EVENT SERVER` / `EVENT SERVERS` / `EVENT HOST` / `EVENT CLIENT` / `EVENT CLIENTS` directional forms shipped in 1.27.0 — but they no-op'd entirely in single-player mode (no NetState, no wire to send anything across). That meant authors who wrote a fully-targeted multiplayer game couldn't test it solo without rewriting all the `EVENT SERVER "X"` calls as `EVENT "X"`.

1.28.0 collapses every targeted form to a local in-process dispatch when there's no NetState. Slot/room arguments are accepted but ignored (solo has only one "slot" and no rooms). Result: write the game once with all the targeted EVENT forms; run it solo to test the logic; deploy to multiplayer where the targeting actually routes across the wire.

```basic
' Same code runs in both solo and multiplayer:
EVENT HOST "SaveProgress" (ME.SLOT, level, score)
EVENT CLIENTS "RefreshScore"
EVENT SERVER 1 "Tick" (now)

' In solo: each call fires the local handler (registered via CALL EVENT).
' In multiplayer: the targeting routes to the right interpreter per the
' authority matrix (host > player-server > local-client).
```

The authority matrix from 1.27.0 still applies in multiplayer — a local-client trying to do `EVENT CLIENT n "X"` is fatal there, just as before. Solo skips the authority check entirely since there's only one execution context.

---

## Splash screen fires for multiplayer games

1.27.0 shipped the licensing splash on local single-player launches but skipped it for multiplayer entirely — `run_remote_game` (the path used by `--remote` and the `crashnet://` descriptor flow) had no stamp verification and no splash phase at all. A Free-tier game launched via the multiplayer flow shipped without the splash its license required. Money-leak for commercial deployments.

1.28.0 fixes this. `run_remote_game` now reads the cart's stamp, derives the same `SplashParams` the local path uses, and runs the splash phase before the WebSocket connect. Free-tier multiplayer games show the splash; Studio-tier with `remove_splash` skips it; everyone in between gets the duration their tier is licensed for.

The splash runs **before** the connect attempt, so an unreachable server still produces the splash followed by a clear error dialog (instead of the user staring at a black screen during a failed connect). The same gfx / audio / video pipeline the splash creates carries through into the game phase, so there's no mid-session restart of the renderer.

---

## What didn't change

The wire format (JSON + protobuf), single-player behavior on file-loaded `.crashcart` games, the Arcade-passthrough auth path, every existing CrashNet feature from 1.27 (rooms, local authority, PLAYER DATA / HOST DATA replication, dashboard polish, deploy pack, single-port WS+admin, kick-on-crash + retry, multi-instance `crashnet://`), every existing GUARD-using `.bas` (after the `SINGLE` → `ONCE` rename + `SINGLE`-now-means-solo migration), and the WASM player flow all stay where they are.

The original `CREATE MAP("file.map.json") AS LAYER N` form keeps working unchanged — none of the 1.28.0 tilemap work breaks any cart that loads its maps from the editor's `.map.json` files.

Default JWT iss/aud values still match Arcade's (`crashbasic-arcade` / `crashbasic-client`) so existing Arcade-issued tokens validate without operator action.

1483 unit tests pass.
