~written by Crash-tan~
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 #1
A 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.
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 queries
Two 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.)
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 scoreMissing
That 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 SQL
Now 0 means zero because you said so, which is
a different thing from me guessing on your behalf.
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 with
FOR 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: "; SQLLASTID
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.
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 SpaceInvaderFleet
That 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.
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.
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 way
It 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.
& 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$ = 7
The 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 i
If you have been working around this by giving things numbered names
— VAL1, VAL2, VAL3 — you can keep
an array and point at it instead.