Crash-tan
SQLite3 in your CrashCarts!
Crash BASIC 1.56.0 Release Notes
written by Crash-tan
YOUR GAME CAN HAVE A DATABASE NOW
Real SQLite, inside Crash BASIC, with the query written where you can see it:
SQL "topScores"
USE NUMBER minScore DEFAULT 0
USE NUMBER limit DEFAULT 10
SELECT name, score FROM runs
WHERE score >= @minScore
ORDER BY score DESC LIMIT @limit
END SQL
OPEN DATABASE "player.db" FOR OUTPUT AS #1
CALL SQL "topScores" ON #1 (@minScore=1000, @limit=5) INTO rows
DIM i AS INTEGER
FOR i = 0 TO UBOUND(rows)
PRINT rows(i).name; " "; rows(i).score
NEXT i
CLOSE #1A database opens on a handle, like a file. A read finds a database bundled in
your cart the same way it finds an image. FOR OUTPUT writes to wherever the
platform you are running on keeps save data — you never have to know where that
is, which is the same deal OPEN ... FOR OUTPUT has always given you. CLOSE
covers both, because they share one handle space.
Ship a world in a .db: every item, every line of dialogue, every level's
layout, queryable. Or keep a save that is more than a blob — a high score table
you can actually sort, an inventory you can actually search.
THE BLOCK IS REAL SQL
Not a wrapper, not a dialect. What you write between SQL "name" and END SQL
is handed to SQLite exactly as you typed it, which means every SQL feature is
already there and every SQL answer you find online already applies.
The parameters look familiar for a reason. SQLite's own named-parameter syntax
is @name — the same @name a BEHAVIOR takes:
SET SPRITE i BEHAVIOR "Bouncer", @r=0.55 ' behaviors, since forever
CALL SQL "addRun" ON #1 (@who$="Dan", @score=1200) ' and now queriesTwo languages that had never met turned out to spell it the same way, so nothing
has to be rewritten, escaped or quoted.
That is also why SQL injection cannot happen here. A parameter is bound as a
value by SQLite itself; it is never pasted into the statement text. A player
whose name is Robert'); DROP TABLE users;-- gets a high score entry with a
silly name, not a missing table. There is no filtering to forget, no escaping to
get wrong — a value simply has no route to becoming syntax. (Yes, there is a
test named after him.)
ABOUT NOTHING
SQL has NULL. Crash BASIC does not, on purpose. So when a query hands back "no
value", I have to do something with it, and the tempting answer — call it 0 —
is a trap I refuse to walk into: once a missing score and a score of zero are
both 0, nothing you write afterwards can ever tell them apart. The information
is gone at the moment I convert it.
So I stop and say so:
## Crash-tan here!
SQL "topScores" returned NULL for column `score` (row 3).
A missing score and a score of zero are different facts, and once I picked
one for you there would be no way to tell them apart again.
Three ways to say what you mean:
· Give it a value in the query: IFNULL(score, 0) AS score
· Declare it once in the block: NULLS score = 0
· Keep them distinct: score IS NULL AS scoreMissingThat third one is the good one, and it costs nothing: SQL already knows how to
answer "was this missing?" and hands you back an ordinary 1 or 0.
If absence really does mean zero for a column, say it once and never think about
it again:
SQL "allRuns"
NULLS score = 0, name = "(unknown)"
SELECT name, score FROM runs
END SQLNow 0 means zero because you said so, which is a different thing from me
guessing on your behalf.
RESULTS ARE OBJECTS
A query that returns rows binds INTO an array of objects, with your column
names as fields — rows(i).name, rows(i).score. Walk them withFOR i = 0 TO UBOUND(rows). Object fields take no $, whatever the column
holds, so it is rows(i).name even though the name is text. Give a computed
column an AS name and you can read it the same way.
A query that changes something reports what it did instead:
CALL SQL "addRun" ON #2 (@who$="Dan", @score=1200)
PRINT "rows: "; SQLROWSAFFECTED; " id: "; SQLLASTIDTHINGS WORTH KNOWING
It saves on CLOSE. Your changes live in memory while the handle is open
and are written back when you close it — the same moment a buffered output file
is written. End without closing and you lose them, exactly as you would with a
file. CLOSE #1 when you are done.
A read cannot damage a cart. A database opened without FOR OUTPUT is never
written back, so shipping content in a .db is safe from the program reading it.
Multiplayer already knows the rules. Opening FOR OUTPUT on a client is
refused, like any other client-side write — the server owns the bytes, and it
already has per-player storage to put them in.
It works everywhere, including the browser. Desktop, iOS, tvOS, Android and
the web player all run the same SQLite — the same C library, compiled for each
target — through one implementation. Not a JavaScript stand-in with its own
quirks: the same engine, the same file format, the same rules about parameters
and NULLs. A database you build on your desktop opens byte-for-byte in a
browser, and a save written in a browser opens on a phone.
A TYPE CAN HOLD AN ARRAY NOW
Fields take array bounds, spelled exactly the way DIM spells them:
CONST ROWS = 5
CONST COLS = 11
TYPE SpaceInvader
hp AS INTEGER
alive AS BOOLEAN
END TYPE
TYPE SpaceInvaderFleet
invaders(0 TO ROWS - 1, 0 TO COLS - 1) AS SpaceInvader
speed AS SINGLE
END TYPE
DIM fleet AS SpaceInvaderFleetThat is the whole fleet in one variable — a grid of invaders and the speed they
move at, together, because they are one thing. Any number of dimensions, and
the element can be another TYPE.
Every cell arrives already built: a real SpaceInvader with its fields set to
their defaults, not an empty box you have to fill in first. Two fleets never
share cells, so a second one starts fresh.
The bounds are worked out once, when the TYPE runs, so use CONSTs for them.
AND YOU CAN CALL A METHOD ON WHATEVER YOU REACHED
CALL used to stop after one step. Now the receiver is just the path to the
thing, however far in it lives:
CALL fleet.invaders(r, c).Hit(1)
CALL army.squads(0).members(2).Hit(1)
IF fleet.invaders(r, c).IsDead() THEN CALL fleet.Remove(r, c)SELF inside the method is that invader — not a copy of it — so what the method
writes is there when you look again, and the invader beside it does not move.
Timers, threads and render callbacks take the same paths.
DIVIDING A COORDINATE FINALLY MEANS WHAT YOU EXPECT
Multiplying a coordinate by a number has always scaled it. Dividing one did
not — it quietly handed you the length instead, so pos / 1 gave you 5 where
you had written (3, 4). Now the two match:
vel = (10, -6)
vel = vel * 2 ' (20, -12)
vel = vel / 2 ' (10, -6) — used to be 11.66…
mid = (a + b) / 2 ' the midpoint, written the obvious wayIt divides each component rather than multiplying by one-over-the-number, so(3, 6, 6) / 3 lands exactly on (1, 2, 2) — no 0.999… creeping into a
position you meant to be whole.
Dividing by a coordinate is a different question and still answers with a
number: q / p tells you how many times longer q is than p.
A HANDLE CAN NOW POINT AT AN ARRAY ELEMENT
& turns a string into a place you can read and write. It always understood a
variable name, and a field down a chain of objects — but not a subscript. Ask it
for "enemies(3).hp" and it handed back 0 without a word.
Now the whole path works, in both directions:
ref$ = "squad.members(1).hp"
&ref$ = 50
PRINT &ref$ ' 50
ref$ = "grid(2, 3)" ' any number of dimensions
&ref$ = 7The subscript can be a variable, and it is resolved when you use the handle, not
when you build it:
FOR i = 0 TO 7
ref$ = "enemies(" + STR$(i) + ").hp"
&ref$ = 100
NEXT iIf you have been working around this by giving things numbered names — VAL1,VAL2, VAL3 — you can keep an array and point at it instead.