Crash-tan Crash-tan

CrashPlayer 1.32.0 released!

Crash BASIC 1.32.0 Release Notes

written by Crash-tan

THE "GO FETCH" RELEASE

This is a big one. Three cycles of work that never shipped publicly are
rolling out together: first-class concurrency + networking (the
headline), the "write once, run anywhere, but honestly" cleanup pass,
and the foundation for in-app purchases through your own CrashNet
server
(mock-tested, with real Stripe wiring — flagged alpha, see
that section).

The short version of the headline: your .crash can now call an HTTP
endpoint, fire a bunch of calls in parallel, and turn the bytes that come
back into a sprite or a sound — without ever leaving BASIC.

  • ASYNC + RESOLVE() — first-class concurrency. Start the work,
    get a promise, resolve when you need the answer.
  • FETCH() — synchronous HTTP. JSON bodies, headers, the same shape
    on every platform.
  • BASE64TOIMG / BASE64TOWAV / BASE64TOMP3 — function-form
    siblings of LOADIMG/LOADWAV/LOADMP3 that take base64 instead of a
    filename. The intentional pair to FETCH.

ASYNC + RESOLVE — concurrency you can actually USE

CrashBASIC already had CALL THREAD for fire-and-forget work. What it
didn't have was a way to start something in parallel and get a value
back
. That's the entire ASYNC story.

FUNCTION Double(n)
    Double = n * 2
END FUNCTION

p = ASYNC Double(21)     ' spawns a worker, returns a Promise immediately
result = RESOLVE(p)      ' blocks until done, returns 42

ASYNC funcName(args) is a prefix on a function call. It evaluates the
args right there in your scope (so changes to your locals after the spawn
don't leak into the worker), kicks off a thread, and hands you back a
Promise. Store it in a variable, pass it to a function, stick it in an
Object — it's just a value.

RESOLVE is the consumer. Hand it one promise and it gives you the value
as-is. Hand it several and it gives you an array of values, in
declaration order:

p1 = ASYNC LoadLevel(1)
p2 = ASYNC LoadLevel(2)
p3 = ASYNC LoadLevel(3)

' Three loads in parallel. Total wall-clock = the slowest one.
results = RESOLVE(p1, p2, p3)
level1 = results(0)
level2 = results(1)
level3 = results(2)

Re-resolving the same promise gives you the cached answer — no second run.
Works on every kind of function: BASIC FUNCTIONs, BASIC SUBs, native
built-ins. Misspell the name or get the arity wrong and you halt at the
ASYNC line — not buried inside a promise you'll never check.

One thing to know: errors inside the async body still halt the whole
program. ASYNC is for parallelism, not for "try this and pretend it
didn't fail." Defensive recovery is still on you.


FETCH() — the network in two lines

r = FETCH("https://api.example.com/score?player=42", "GET")
IF r.STATUSCODE = 200 THEN
    PRINT "got: " + r.MESSAGE
END IF

That's the whole shape. FETCH(url, method, body?, headers?) always
returns an Object with exactly two fields:

  • STATUSCODE — the HTTP status (200, 404, 500, …) on a real
    exchange. 0 when the request never got there: bad URL, DNS
    failure, connection refused, TLS hiccup, timeout. Anything pre-HTTP.
  • MESSAGE — the response body as text on success, the error
    description when STATUSCODE = 0. 4xx/5xx? You still get the server's
    body.

The important part: network errors don't halt your game. A flaky API
is a runtime condition you can detect and recover from, not a fatal
crash. Bad arity or a non-Object headers argument will halt — those are
call-site bugs and we want them loud.

Bodies, automatically

Strings go through as-is. Objects and Arrays auto-encode as JSON with the
right Content-Type:

DIM payload AS OBJECT
payload.player = "alice"
payload.score = 4200
r = FETCH("https://api.example.com/scores", "POST", payload)

Headers

Optional 4th arg, just an Object:

DIM headers AS OBJECT
headers.Authorization = "Bearer " + token$
r = FETCH(url, "GET", "", headers)

Pass anything other than an Object as headers and you get
STATUSCODE = 0 with a useful message. No halt.

Parallel fetches

This is where ASYNC FETCH(...) earns its keep:

p1 = ASYNC FETCH("https://api.example.com/leaderboard", "GET")
p2 = ASYNC FETCH("https://api.example.com/news", "GET")
p3 = ASYNC FETCH("https://cdn.example.com/sprite.png", "GET")
results = RESOLVE(p1, p2, p3)

Three calls go out simultaneously. You wait once. Total time = the
slowest call.

Across platforms

Same return shape, same field names, same error sentinel everywhere:

  • Mac / Windows / Linux / iOS / Android — real HTTP client, 30-second
    timeout.
  • WASM (browser) — synchronous fetch in the Worker your interpreter
    runs on. Cross-origin URLs obey the browser's CORS rules, like any
    web app: same-origin always works; third-party APIs work if the server
    sends the right CORS headers (enforced by the browser, not by us).

Synchronous FETCH blocks the thread it's on. If you don't want your
game to freeze for a slow request, wrap it in ASYNC FETCH(...).


BASE64TOIMG / BASE64TOWAV / BASE64TOMP3

The natural completion of FETCH. You got a .png back from the
network — now what? Now this:

r = FETCH("https://cdn.example.com/player.png", "GET")
IF r.STATUSCODE = 200 THEN
    BASE64TOIMG(r.MESSAGE, "player")
    PUTIMAGE (100, 100), "player"
END IF

Same downstream behavior as LOADIMG / LOADWAV / LOADMP3 — they
register the asset under the name you give, and from there it's just
another sprite or sound clip. Errors halt loud, exactly like the LOAD*
statements.

The base64 input can be a raw base64 string OR a full
data:image/png;base64,... URI — the prefix is stripped automatically.
BASE64TOIMG takes the same sprite-sheet shape as LOADIMG
(BASE64TOIMG(data$, "tileset", 16, 16)). BASE64TOWAV / BASE64TOMP3
are strictly (base64, name), and like their LOAD* siblings, audio
loading is client-only — server-side multiplayer interpreters reject it.


Write once, run anywhere — but honestly

Two case-sensitivity gaps that made the same .crash behave differently
depending on the host. Both closed:

  • Audio handles are case-insensitive now. LOADWAV "shoot.wav", "Shoot" followed by PLAYWAV "shoot" used to silently find nothing;
    the image side already worked this way. Now every audio handle lookup
    (LOAD*/PLAY*/STOP*/FADE*/UNLOADSOUND/SET INSTRUMENT … WAV/
    the new BASE64TO*) is case-insensitive.
  • File paths are case-insensitive on native too. LOADWAV "HELLOWORLD.WAV" now finds HelloWorld.Wav on Linux and case-
    sensitive WASM hosts, not just Mac/Windows. The runtime tries the exact
    path first (fast path) and falls back to a case-insensitive walk inside
    the sandbox — directory names and filenames both count. FILE_EXISTS$
    uses the same resolver, so the two always agree.

In-app purchases via your own CrashNet server

🚧 The IAP layer is alpha. The rest of Crash BASIC is past that
point, but the in-app-purchase surface — the protocol, the Stripe
wiring, the refund handling — is brand new and hasn't soaked in
production-shaped traffic yet. Build cool things, run sandboxes, do
not bet your bank statement on it. If you flip the Stripe backend
live anyway, you're taking on the risk that an alpha-cycle bug eats a
real customer's money, and there's no support contract underneath you.

⚠️ This is the part that takes real money. Point your
crashnet-server at a live Stripe account with a sk_live_… key and
a customer hits Buy, and money moves from their card to your bank. Test
in sandbox first with sk_test_… keys and Stripe's test cards
(4242 4242 4242 4242 succeeds; 4000 0000 0000 9995 declines — full
list at https://docs.stripe.com/testing). The mock backend is the
default; nothing charges until you opt in.

📡 CrashNet-only for now. IAP runs through a crashnet-server you
(or your host) run — catalog, ledger, and the Stripe round-trip all
live server-side. Pure single-player games that don't connect to a
server can't take payments yet.
Apple StoreKit / Google Play Billing
are stubbed in the iap.json schema but not wired to working backends.

For BASIC programs, the IAP surface is unchanged — same PURCHASE NEW,
FOR PURCHASE, PURCHASE.* pseudo-variables, and the "purchase"
event. What's new is that your CrashNet server can be the payment
authority.
It already has JWT auth, a per-user database, and a
WebSocket listener, so now it also mounts an /iap route (automatically,
whenever the game directory has an iap.json).

  • Two backends, same wire shape. MockIapBackend (the default)
    pretends every purchase succeeds after 500ms and never charges anyone —
    ideal for development and reward-grant models. StripeIapBackend
    (when you set STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET) creates
    real Stripe Checkout sessions, opens the user's browser, and completes
    on the webhook. Your game code can't tell which is on the other end.
  • stripePriceId per product in iap.json links each product to a
    Stripe Price. The mock ignores it; Stripe requires it and refuses
    (per-product, with a clear error) any product missing one — silent
    fallback would be a money leak.
  • Refunds and disputes auto-revoke. A full refund or a lost dispute
    pulls the matching non-consumable: the DB row is deleted and, if the
    player is online, their PURCHASE.PURCHASED flips to false in real
    time with a "purchase" event (success = 0). Consumables stay as
    history — clawing back already-spent coins is a game-policy call you
    make manually.
  • Three ways to configure secrets: --stripe-secret-key /
    --stripe-webhook-secret flags, STRIPE_SECRET_KEY /
    STRIPE_WEBHOOK_SECRET env vars, or the new Stripe panel in the
    crashnet-server admin UI
    (with a TEST/LIVE/NOT-CONFIGURED badge).
    CLI/env wins over the DB; setting one via env locks that field in the
    UI.
  • One login covers multiplayer AND IAP. The same --token JWT works
    for both routes. Players already in your lobby buy without
    re-authenticating.
  • Server-side BASIC sees purchases, including mid-session ones —
    per-player, isolated, hydrated at join and updated live.
  • Fail-loud safety nets everywhere: half-configured Stripe secrets
    abort at startup; secrets set against a binary built without
    --features stripe warn and fall back to mock; webhook signature
    failures return 400; products without a price ID refuse individually.

For local webhook testing, run
stripe listen --forward-to localhost:9005/iap/webhook (install the
Stripe CLI: https://docs.stripe.com/stripe-cli) — it prints the
whsec_… you paste into the admin UI.


Compatibility notes

  • No multiplayer/protocol changes. CrashNet, crashgfx, crashaudio —
    wire-compatible with 1.29.x. Mix and match while you roll out.
  • ASYNC is a reserved prefix. ASYNC funcName(...) is the new
    form; identifiers that merely start with those letters (ASYNCfoo)
    still parse fine — only literal ASYNC before a function call triggers
    it.
  • Audio handle behavior changed. Code that intentionally relied on
    "Boom" and "boom" being different sounds no longer works. (You
    almost certainly don't have any.)
  • DB schema is additive. Existing crashnet databases auto-migrate on
    next open (idempotent CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS); SQLite and Postgres both have full IAP parity.
  • Default behavior unchanged. No Stripe env vars = mock backend; no
    iap.json = no /iap route at all.

What's NOT in 1.32.0

  • Subscriptions / recurring billing. Stripe Checkout supports it; we
    do one-time payments only. Needs BASIC-side design.
  • Auto-restore on a won dispute. If you win a chargeback back after
    we've revoked, the customer is re-granted manually.
  • In-process Stripe secret hot-swap. Saving secrets in the admin UI
    needs a server restart to take effect.
  • Platform-native IAP (Apple / Google) and pure-offline IAP. Server-
    mediated only for now.

Go fetch. (And maybe charge a couple bucks for the privilege — once
you've read the test-card list.)

— Crash-tan