Crash-tan
CrashCore 1.29.0 launched!
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.
' 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 WITHThe 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 CLIENTSunder 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" withoutIF ME.ISHOSTgating. - 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
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 IFLAYER/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:
WITH PLAYER n SYNC (args)
' body runs on player-server n
END WITH
' execution resumes here ONLY after the body finishedSame 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 SYNCis fully in-process on the server. The host's pending-sync map (on the sharedNetState) holds a flag the player-server's dispatcher flips after the body completes. No new wire frames.WITH CLIENT SYNCadds two wire bits: theclient_signalJSON gains an optionalreply_tokenfield, and a newnet_client_signal_ackinbound JSON message carries the token back. The server's reader task routes acks directly toNetState::deliver_sync_replywithout going through the bridge tick.
What WITH PLAYER / WITH CLIENT compile to
Behind the scenes the AST builder rewrites:
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:
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.
' 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 IFPlayers 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
WITHblocks behave identically; on the host, statements outside anyWITH ROOMblock 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 inWITH ROOM "..."and letting the runtime do the filtering. - Single-player code: no required changes.
WITH LAYERis a drop-in replacement forSET LAYERboilerplate 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.
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, instantlyThe 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:
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. IfSCREEN w, hruns during the scene, post-END SCENEyou'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 SCENEmachinery; 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. UseBEGIN 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:
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 TILEMAPTILEMAP 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-DIMs 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 untilSET ROOM "x"brings them on screen. Global content (noSYNC/ outside anyWITH 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-processNetState, but the getter still expected the absence of NetState as the SINGLE signal — so it fell through to the!is_serverarm 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 insideWITH ROOM "x"without an explicitSYNC/NOSYNCmodifier picks up"x"as its room. Explicit modifiers still win.Solo
SET ROOMnow 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.
CRASHNET
TICK RATE 30 ' explicit; previous default of 20 still works
...
END CRASHNETSingle-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 windowGenerous 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 MAPoutside itsWITH ROOMblock broadcasts unfiltered. IfCREATE MAPran insideWITH ROOM "lobby"butUPDATE 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 sameWITH ROOMblock.WITH PLAYERargs 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 toPLAYERS(n).fielddirectly.