Crash-tan
Version 1.58.0 Released
Crash BASIC 1.58.0 Release Notes
written by Crash-tan
Your game now packages for the browser. One folder, one zip, and it plays on
itch.io — or anywhere else that can serve a static site.
crashplayer package --platform web
crashplayer package MyGame.crashcart "My Game" --platform webOut comes My_Game-web/ and My_Game-web.zip: an index.html, the engine, and
your cart. Upload the zip to itch.io, tick one checkbox, and that is the
whole job. In the launcher it is the Web (itch.io) box on the Package tab,
beside the ones you already use.
The checkbox is not optional. On the game's page, under Embed Options →
Frame Options, tick SharedArrayBuffer support. The engine is threaded, and
threads need a browser guarantee called cross-origin isolation that itch.io only
turns on when you ask. Miss it and the page tells you so in as many words,
rather than dying inside the engine with something unreadable — which is what
every other threaded engine does, and why "my web build shows a black screen" is
such a common cry for help.
Hosting it yourself instead? Serve the folder withCross-Origin-Opener-Policy: same-origin andCross-Origin-Embedder-Policy: require-corp, and it behaves identically.
The export is self-contained, on purpose
Nothing in it reaches the network. Not a font, not a script, not a library —
the engine, the cart and the page are all in the zip you upload.
That is worth a sentence of explanation, because it cost something to do. The
easy version of a browser cart reader is a few hundred lines of JavaScript that
imports its cryptography and decompression from a public CDN, and it works
perfectly — until the day it doesn't. A game on itch.io is uploaded once and
then sits there for years, long after anyone is watching it. So the engine reads
carts itself now, in the same Rust the desktop and mobile players use, and the
export depends on nobody.
It also means your licence stamp is read where it should be. The splash follows
exactly the rules it follows everywhere else: the tier comes from the signed
stamp in your cart, and is read by the engine rather than handed to it by the
page. Unstamped, unverified or tampered carts are all Free tier, which is the
one that shows the splash.
What works, and what to know
Saves work — they go into the browser's own storage, filed under your cart's
game id. That last part matters more than it sounds: with SharedArrayBuffer
enabled, every game on itch.io is served from the same address, so saves that
weren't filed by game would land on top of each other. Yours won't.
Sound, graphics, input and behaviors are the engine you already know; this is
the same runtime the Arcade plays your games with, not a reduced one.
You are not left staring at a blank page while that arrives, either. The
loading bar is weighted by real bytes across the engine and your cart, so it
moves in proportion to the wait rather than jumping from nothing to everything
at the end.
A word on size: the engine is about 38 MB on disk and roughly 19 MB once zipped,
and that is your floor before your game's own art and music. Fine on a desktop
connection, slower on a phone. It is the first thing a visitor downloads before
they see anything, and we are working on bringing it down.
AND A MAC PACKAGING FIX
Notarising a macOS build has been failing since third-party licence notices
started shipping — every macOS game packaged since then, whatever was in it.
Apple rejected them with "the signature of the binary is invalid", pointing at
the game's main executable, which is a fine way to lose an afternoon.
The executable was fine. THIRD_PARTY_LICENSES.txt was being written into the
app bundle after it was signed, and macOS seals a bundle's contents when it
signs them — so adding one file afterwards invalidated the whole thing. It goes
in before the signature now.
If you have a build sitting in the failed pile, you do not need to repackage it:
re-signing is enough, because the file is already there and only the seal was
stale. Or package it again and it will simply work.
AND ANIMATE NOW TAKES A PARAMETERIZED SHEET WHOLE
A behavior can take its image as a parameter — ANIMATE @sheet="hero" — and
three of ANIMATE's conveniences used to be off the table when you did: leaving
the frame list out, FRAMERATE OFSPRITE, and AUTOBBOX. All three are read off
the image, and at the BEHAVIOR block there is no image yet. Only a parameter,
waiting for someone to fill it in.
So now they wait for it too.
BEHAVIOR "walk"
ANIMATE @sheet="HERO" AUTOBBOX FRAMERATE OFSPRITE
END BEHAVIOR
SET SPRITE 1 BEHAVIOR "walk", @sheet = "HERO"
SET SPRITE 2 BEHAVIOR "walk", @sheet = "VILLAIN"One behavior, two characters, each animated by its own Aseprite file: its frame
count, its authored per-frame timing, its own per-frame hitboxes. Nothing is
taken from the default and quietly used for the other one.
The error moved along with the work. Hand FRAMERATE OFSPRITE a sheet that was
never loaded from an .ase file and you hear about it at the SET SPRITE that
handed it over — the statement that chose the image, naming the behavior and the
parameter — instead of at a BEHAVIOR block that could not have known. A sheet
named outright in the behavior is still checked right there, where you wrote it.
AND A COOLDOWN THAT ACTUALLY COOLED DOWN
COOLDOWN inside an EVENT handler never gated anything. Fire the event ten
times in a third of a second and a COOLDOWN 60 in the handler ran all ten
times, exactly as if you had not written it.
Each firing of an event runs in its own context — it has to, because firings
run concurrently and each needs its own variables — and the cooldown's memory
of "when did I last run" was living in that context. So it was born empty every
time, and a block that had just run looked like a block that had never run.
Debouncing an event, which is one of the things COOLDOWN is for, was the
one place it could not work.
The memory is now shared, and a block is identified by where it is written plus
which context is running it. Everything that belongs to one event handler shares
one gate, so ten firings debounce to one. Everything else keeps the behaviour it
had: the main program, each CALL THREAD, each CALL TIMER, and the tick
context that runs collision handlers and CALL RENDER callbacks all still hold
their own gates, so the same line reached from two of them is still two
independent cooldowns.
While we were in there: COOLDOWN N is N/60 of a second of wall-clock time —COOLDOWN 1 is 16.7 ms — and it always was. Nothing about the timing changed.
AND JSON CAN NOW BECOME A REAL TYPE
You can build an object out of thin air — a JSON literal in code, a LOADJSON
of a save file, a DESERIALIZE$ of something off the wire. What you could not
do was tell Crash BASIC what it is. It arrived as a bag of keys, and a bag of
keys has no methods.
Now you say so, at the end of the assignment:
TYPE Player
name AS STRING
hp AS INTEGER
pos AS Point
END TYPE
SUB Player::Damage(n)
SELF.hp = SELF.hp - n
END SUB
p = {"name": "Ada", "pos": {"x": 4}} AS Player
CALL p.Damage(30)p is a real Player. Its methods dispatch, SELF works inside them, and it
behaves exactly like one you had written DIM p AS Player for — because it is
built the same way. The type's instance is created first, with every declared
field at its default, and your object is laid over the top. What you supplied
wins; what you left out keeps the default. So p.HP is 0 rather than missing,
and a method that reads it cannot trip over a hole your JSON happened to leave.
A field declared as another type coerces its nested object too, so p.POS is a
real Point with its own methods, not a plain {"x": 4}. Values become what
the field declared — "42" into an INTEGER field is 42. Keys your type
never declared are kept, because an instance stays dynamic, exactly as aDIM'd one does.
The clause is on the assignment, not on the literal, which is the point:
save = LOADJSON("savegame.json") AS SaveGame
foe = DESERIALIZE$(payload$) AS EnemyA save file loaded from disk is now a typed thing with behaviour attached,
rather than data you have to copy field by field into something that has it.
AND SET SCROLL NO LONGER PAYS FOR STANDING STILL
Tell a layer to scroll to where it already is and nothing happens now — not
"nothing visible", nothing at all. The command is dropped before it is ever
sent.
That sounds like a rounding error. It is not, and here is why: a scrolling
backdrop is usually written as an unconditional line in the render loop, because
that is the honest way to write it —
WITH LAYER 1
SET SCROLL 0, INT(scrollY / parallax)
END WITH— and a parallax divisor means that value only actually changes every few
dozen passes. Every pass in between was re-stating a fact the renderer already
knew. Worse, a loop paced with SET YIELD 1 runs far faster than the screen
refreshes, so those repeats pile up between frames rather than replacing each
other.
In a real game, measured: SET SCROLL was 93% of everything the graphics
engine was asked to do, and the whole command load dropped 43% once the
repeats stopped. On a fast desktop you would never have noticed. On weaker
hardware — where the renderer is already the thing you are waiting for — that is
the difference between a smooth backdrop and a stuttering one, and it was the
reason a fast game loop could feel slower than a slow one.
Nothing about scrolling changed. It moves when you move it, at exactly the rate
you move it. A scroll that genuinely advances every pass still sends every pass,
because that one is not a repeat — it is animation, and it is what you asked
for. Camera angle counts as part of the position too, so a plane layer that
rotates in place is a change and gets sent.
The practical upshot: you no longer have to hand-pace your scroll to keep it
cheap. Put it in the loop, let it run as fast as your game runs, and it will
cost you exactly as much as it actually moves.
AND EXIT NO LONGER CLOSES A FRAME IT DID NOT OPEN
Wrap your render pass in BEGIN FRAME / END FRAME, call a helper that
happens to contain an EXIT FOR, and the END FRAME failed: "Not in a
frame." Nothing you wrote was wrong. The frame had been closed out from under
you, several calls deep, by a loop that had nothing to do with it.
EXIT FOR, EXIT DO and EXIT WHILE used to close any open frame on their way
out. The idea was to rescue a program that leaves a loop from inside a frame —
DO
BEGIN FRAME
IF quit THEN EXIT DO ' END FRAME never runs
END FRAME
LOOP— which does strand a frame, and END FRAME is what makes drawing visible, so a
stranded one freezes the screen. Reasonable problem. The trouble is that the
rescue could not tell this loop's frame from a frame that was already open
before the loop started, because all it had to go on was "is a frame open right
now". So a retry loop buried in a helper — the ordinary kind, pick a spot, check
it, EXIT FOR when it will do — reached up and closed the frame its caller had
opened.
It does nothing now. A frame is closed by END FRAME, by the code that opened
it, and by nothing else.
Which puts one thing on you: if you open a frame, you close it. Leaving a
loop from inside a frame means closing it on the way out —
DO
BEGIN FRAME
...
IF quit THEN
END FRAME ' <- close what you opened, then go
EXIT DO
END IF
END FRAME
LOOPThat is the same deal every other block in the language gives you, and it is
worth the swap: the old rescue only worked when the frame belonged to the loop,
and silently broke every case where it didn't. If you do leave one open, the
display stops updating until it is closed — END FRAME is what makes drawing
visible — so it shows itself immediately rather than corrupting a frame count
you never see.
Nested frames were the worse case, and are also fixed: the old behaviour closed
exactly one level per exit, so a frame inside a frame came out half-shut and
every count after it was off by one.