CrashNet Server 1.56.0 Release Notes

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 #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.

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 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.)

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 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.

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 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

THINGS 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 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.

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 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.

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$ = 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.