CrashNet Multiplayer System
CrashNet is the server-authoritative multiplayer system for Crash BASIC.
The same .bas runs in three execution contexts at once — a headless
host server, one server-side per-player interpreter per connected player,
and the player's own local CrashPlayer — with GAMECONTEXT$ /ME.ISHOST / ME.ISSERVER / ME.ISCLIENT selecting which body to run
where. The same source file additionally runs in a fourth, solo context
(GAMECONTEXT$ = "SINGLE") for offline play.
This doc covers the runtime architecture, the authoring model (hoisting,GUARD, WITH blocks, rooms), the wire protocol, and operations
(authentication, server commands). A small lobby-room game is used as
the worked example throughout; the full source is in
§19.
Table of contents
- Architecture overview
- The hoisting model
- The CRASHNET configuration block
GAMECONTEXT$and the four execution contextsGUARD— context restrictionWITHblocks — ambient context + compile-time hoisting- Rooms — replication scoping
- Sprite ownership and ID spaces
- Server vs client authority
- Broadcasting and per-player perspectives
- Lifecycle events
- Custom events
- PLAYER DATA / HOST DATA as a synchronization mechanism
- Input streaming
- Disconnect handling
- Patterns and antipatterns
- Authentication
- Running the server
- Worked example: lobby-room game
- Network protocol
- Module reference
- Design principles
1. Architecture overview
┌─────────────────────────────────────────────────────────────────────────┐
│ CrashCore NetServer │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ NetManager (Async Runtime) │ │
│ │ - Accepts WebSocket connections │ │
│ │ - Validates JWT tokens │ │
│ │ - Assigns player slots │ │
│ │ - Spawns one player-server interpreter per connected player │ │
│ │ - Runs tick loop for state sync (TICK RATE Hz) │ │
│ │ - Routes outbound gfx/audio + inbound input/events │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┼──────────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Host │ │ Player-svr │ │ Player-svr │ │
│ │ (slot 0) │ │ (slot 1) │ │ (slot N) │ │
│ ├─────────────┤ ├─────────────┤ ├─────────────┤ │
│ │ headless; │ │ one per │ │ ... │ │
│ │ authority │ │ connected │ │ │ │
│ │ for shared │ │ player; │ │ │ │
│ │ world + │ │ authority │ │ │ │
│ │ pattern tick│ │ for sprites │ │ │ │
│ │ │ │ it creates │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └─────────────────────────┼─────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Shared NetState (Arc<>, lock-protected) │ │
│ │ - PLAYERS roster with connection state + custom PLAYER DATA │ │
│ │ - HOST DATA fields │ │
│ │ - Sprite ownership (creator_slot per sprite ID) │ │
│ │ - Per-player scroll offsets (one per layer) │ │
│ │ - Per-player current room (ME.ROOM) │ │
│ │ - Lifecycle event queue (joins, leaves, host changes) │ │
│ │ - Pending-SYNC ack map (for WITH PLAYER SYNC / WITH CLIENT SYNC) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Shared BehaviorEngine (server side only) │ │
│ │ - Sprite world coordinates │ │
│ │ - Behaviors and patterns │ │
│ │ - Ticked by host only (prevents N× speed scaling) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│ ▲
│ outbound gfx/audio (room-filtered) │ inbound input,
│ state ticks, lifecycle events │ EVENT HOST/SERVER,
│ WITH CLIENT bodies (via EVENT CLIENT*) │ WITH CLIENT SYNC acks
▼ │
┌─────────────────────────────────────────────────────────────────────────┐
│ CrashPlayer (one per connected user) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Local-client interpreter (ME.ISCLIENT = 1) │ │
│ │ - Own NetState + own BehaviorEngine for own (client-only) sprites │ │
│ │ - Renders server-broadcast sprites in server ID space (0–2000) │ │
│ │ - Owns local-only sprites in client ID space (32768+) │ │
│ │ - Forwards opted-in input (CRASHNET STREAM KEYBOARD / MOUSE) │ │
│ │ - Fires EVENT HOST / EVENT SERVER custom signals │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘The two server-side contexts share one NetState and oneBehaviorEngine. The local-client context has its own NetState and its
own engine for the client-only sprite namespace, while rendering
server-authoritative sprites from the broadcast stream.
A fourth context, solo (GAMECONTEXT$ = "SINGLE"), runs in a single
in-process interpreter with an in-process NetState attached (so all
the room/sprite/replication paths still work) but no WebSocket, no
NetManager, no player-servers — see §4.
2. The hoisting model
The single most important mental shift for writing CrashNet code is
hoisting: every top-level declaration is hoisted into every
interpreter at startup. The same identifiers, the same handlers, the
same shapes — in the host, in every player-server, in every client, and
in solo mode. Context-specific behavior comes from explicit branching
(GAMECONTEXT$, IF ME.ISHOST, GUARD) and explicit dispatch (WITH PLAYER n, WITH CLIENT, EVENT HOST, EVENT SERVER). Nothing routes
itself based on where it "looks like" it should run.
2.1 What gets hoisted
Top-level declarations. The CRASHNET block, top-level DIM arrays,
plain variable assignments — all initialized in every interpreter that
reaches that line. PLAYER DATA fields become typed slots on ME andPLAYERS(n).<field>; HOST DATA fields become slots on the shared HOST
object.
CRASHNET
STREAM KEYBOARD
PLAYER DATA
resourcesLoaded AS integer
END PLAYER DATA
HOST DATA
hostLoaded AS integer
END HOST DATA
TICK RATE 20
END CRASHNET
ClientReady = FALSE
DIM RoomTiles(0 to 1, 0 to 9, 0 to 9) AS integerAfter parsing, every interpreter has a ME.resourcesLoaded slot, aHOST.hostLoaded slot, a TICK RATE 20 policy on its tick loop, a
local ClientReady = FALSE, and the RoomTiles array sized identically.
SUBs. Top-level SUB ... END SUB definitions register into every
interpreter's function registry. Whether a SUB actually runs somewhere
is decoupled from where it's defined — that's what GUARD, CALL PLAYER ... GUARD(HOST), WITH PLAYER, and WITH CLIENT are for.
BEHAVIOR / PATTERN blocks. Top-level BEHAVIOR ... END BEHAVIOR andPATTERN ... END PATTERN blocks register the same animations and
patterns in every pattern engine. Sprite pattern assignment resolves
locally; no pattern definitions cross the wire.
BEHAVIOR
"MoveLeft": BMOVE -1, 0
"MoveRight": BMOVE 1, 0
END BEHAVIOR
PATTERN "PlayerSprite"
"MoveLeft" WHEN KEYDOWN("LEFT")
"MoveRight" WHEN KEYDOWN("RIGHT")
END PATTERNPattern triggers do cross the wire when needed: with STREAM KEYBOARD
enabled, client KeyDown/Up events are forwarded to the server so the
player-server's pattern engine sees them just like a local one would.
2.2 Resource loads are safe at top level (1.27.0+)
SCREEN 320, 320
CREATE LAYER 1, 320, 320
NEWIMAGE 32,32,7,"GrayWorld" AS SPRITESHEET(32,32)
TEXTTOIMAGE "👻", "player", 16LOADIMG / LOADFONT / TEXTTOIMAGE / LOADWAV / LOADMP3 /LOADVIDEO are safe to call from any interpreter. Every interpreter
learns the asset metadata (dimensions, frame counts, durations) so
that server-side collision, hitbox, and timing logic can reason about
resources without round-tripping a request to a client. Only the client
actually paints.
Don't wrap resource loads in
IF ME.ISCLIENT THENorGUARD(CLIENT). That was the pre-1.27 pattern and it starves the
server of dimension data — the collision system falls back to a
default 32×32 hitbox for every non-32×32 sprite.
SCREEN and CREATE LAYER are likewise safe at top level. SCREEN is
idempotent on repeat with the same dimensions (1.29.0), so a joining
player's local SCREEN call won't nuke the just-replayed tilemap
delivered by the host's late-join snapshot.
2.3 Server-only and client-only render calls
Two directions of hardening, both fatal if violated:
| Call | Fatal from | Fix-it pattern |
|---|---|---|
PRINT / LOCATE / SETFONT / FONT / CLS / COLOR / BEGIN FRAME / END FRAME / bare-target SET LAYER |
server-side | WITH CLIENT SEND/SYNC body, or EVENT CLIENTS "Name" + GUARD(CLIENT, SINGLE) handler |
SAVETEXT / SAVEJSON / OPEN FOR OUTPUT / OPEN FOR APPEND |
client-side | EVENT HOST "Save" (shared write) or EVENT SERVER "Save" (per-player save) + GUARD(HOST, SINGLE) / GUARD(PLAYER, SINGLE) handler |
These are about authority: the server has no renderer (server-sidePRINT would silently vanish), and the client has no shared filesystem
authority (client-side SAVEJSON would race with other clients and
desync). Both fail loudly with a fix-it hint pointing at the right
pattern.
3. The CRASHNET configuration block
The CRASHNET block sits at the top of the program and defines the
shared shape every interpreter sees. It's hoisted before any other
code runs.
CRASHNET
MAX PLAYERS 4 ' Max connected players (default: 4)
TICK RATE 20 ' Tick loop frequency in Hz (default: 60)
STREAM KEYBOARD ' Forward client KeyDown/Up to server
STREAM MOUSE BUTTONS ' Forward client clicks only
' STREAM MOUSE ' Forward mouse movement too (heavy)
PARTICLES ' Replicate emitted particles
PLAYER DATA ' Per-player replicated fields
score AS INTEGER
team$ AS STRING
END PLAYER DATA
HOST DATA ' Host-authored replicated fields
gameMode$ AS STRING
roundNumber AS INTEGER
END HOST DATA
END CRASHNETPLAYER DATA fields appear on ME for the local player and onPLAYERS(n).<field> for any other player. HOST DATA fields appear on
the shared HOST object everywhere.
Writes propagate via the tick loop (PLAYER DATA on the writer's tick,
HOST DATA on the host's tick). Reads are local — they return whatever
value the most recent tick delivered. Writes are not transactional;
two interpreters racing to write the same field will end up with the
last writer winning at the next tick.
4. `GAMECONTEXT$` and the four execution contexts
Every interpreter knows which context it's running in. The canonical
dispatch is GAMECONTEXT$, a string-valued pseudo-variable:
GAMECONTEXT$ |
Where it runs | ISHOST |
ISSERVER |
ISCLIENT |
|---|---|---|---|---|
"HOST" |
Server-side host (slot 0), headless | 1 | 1 | 0 |
"PLAYER" |
Server-side per-player (slots 1+) | 0 | 1 | 0 |
"CLIENT" |
The player's own CrashPlayer process | 0 | 0 | 1 |
"SINGLE" |
Solo / single-player mode | 0 | 0 | 0 |
The boolean accessors (ME.ISHOST, ME.ISSERVER, ME.ISCLIENT) still
work for short conditionals, but GAMECONTEXT$ is the recommended path
for block-level dispatch — especially since SINGLE is now a distinct
context (1.28.0) that the booleans can't represent cleanly.
SELECT CASE GAMECONTEXT$
CASE "HOST": ' authoritative world setup
CASE "PLAYER": ' per-player server-side logic
CASE "CLIENT": ' local rendering / input
CASE "SINGLE": ' solo mode — usually behaves like CLIENT + HOST fused
END SELECTTypical uses:
' Source player name from console in solo, from network roster otherwise
IF GAMECONTEXT$ = "SINGLE" THEN
INPUT "What is your name?", PlayerName
ELSE
PlayerName = ME.NAME$
END IF
' Run authoritative world setup on host only
IF GAMECONTEXT$ = "HOST" THEN
WITH ROOM "Entrance" ... END WITH
END IF
' Mark this player-server as resources-ready
IF GAMECONTEXT$ = "PLAYER" THEN
ME.resourcesLoaded = TRUE
END IFIf a piece of setup should run in both solo and host (common — the
"solo plays both sides" pattern), guard with both: IF GAMECONTEXT$ = "HOST" OR GAMECONTEXT$ = "SINGLE" THEN ..., or use GUARD(HOST, SINGLE) on a containing SUB.
Pre-1.30.0 versions had a bug where solo runs mis-reported
GAMECONTEXT$ = "CLIENT"(fall-through caused by the in-process
NetState attached for room support). Fixed in 1.29.0 — solo is now
consistently"SINGLE".
5. `GUARD` — context restriction
GUARD(...) is a modifier on a SUB definition or a CALL EVENT /CALL PLAYER / CALL HOST registration. The SUB body is skipped at
invocation time on any context not listed in the GUARD set. Combinable
since 1.28.0:
| Flag | Fires on |
|---|---|
HOST |
The headless host server only |
PLAYER |
A per-player server interpreter only |
CLIENT |
A local-client interpreter only |
SINGLE |
Solo mode only (does NOT fire on host/player/client) |
ONCE |
Drop overlapping invocations (call-once lock) |
GUARD(HOST, SINGLE) ' authoritative logic + solo plays both sides
GUARD(CLIENT, SINGLE) ' rendering / UI; also runs solo
GUARD(SINGLE) ' solo-only branch
GUARD(HOST, PLAYER, SINGLE) ' all server-ish contexts plus solo
GUARD(ONCE) ' overlapping fires of the same handler skipTypical uses:
' Lifecycle handler on the host only — host observes all joins/leaves
CALL PLAYER OnPlayerJoin() GUARD(HOST) AS "PlayerHandler"
' Per-player game loop on the player-server only
CALL PlayerGameLoop() GUARD(PLAYER)
' Render-side handler that fires on every client + solo
CALL EVENT RefreshScore() GUARD(CLIENT, SINGLE) AS "RefreshScore"
' Persistence handler that runs on host + solo
CALL EVENT SaveProgress() GUARD(HOST, SINGLE) AS "SaveProgress"Migration note (1.28.0):
GUARD(SINGLE)used to mean "call-once
lock." That's nowGUARD(ONCE).SINGLEnow means "solo only."
Rename oldGUARD(SINGLE)call-once uses toGUARD(ONCE); if you
actually meant solo-only, the new meaning is correct.
6. `WITH` blocks — ambient context + compile-time hoisting
1.29.0 introduced WITH ... END WITH as a generic block-scoped context
primitive. Four variants:
| Variant | Kind | Effect |
|---|---|---|
WITH LAYER n |
Ambient context | Block draws to layer n; previous restored on exit |
WITH ROOM "name" |
Ambient context | Registrations inside tagged with room "name" |
WITH PLAYER n SEND/SYNC |
Hoisted | Body lifted to a synthetic SUB; ships to player-server n |
WITH CLIENT SEND/SYNC |
Hoisted | Body lifted to a synthetic SUB; routes contextually |
LAYER/ROOM compose freely (orthogonal scoping dimensions). Same-dimension
nesting shadows (inner wins, outer restored on inner pop). PLAYER/CLIENT
can't be usefully nested inside each other — they're one-shot fires, not
ambient contexts — but they can sit inside or alongside LAYER/ROOM blocks
in any order. All WITH blocks pop their frame on exit, including when
the block exits via EXIT FOR/EXIT DO, RETURN, or a halting error.
6.1 `WITH LAYER` — block-scoped layer
' Before
oldLayer = CURRENT.LAYER
SET LAYER 3
CLS 0
PRINT "Score: " + STR$(score)
SET LAYER oldLayer
' After
WITH LAYER 3
CLS 0
PRINT "Score: " + STR$(score)
END WITHPure ergonomic win, no replication semantics. Negative values clamp to
0 (mirroring SET LAYER).
6.2 `WITH ROOM` — room-tagged registrations
Every tilemap, sprite, and layer registration inside the block gets
tagged with the named room. Three effects:
- Outbound broadcasts are filtered. Clients in room
"Entrance"
see"Entrance"content only; clients in"Lobby"see"Lobby"
content only. Content outside anyWITH ROOMblock is tagged with
the reserved sentinel_globaland visible to every client. - Late-join replay is per-room. A player connecting after the host
built rooms gets the full snapshot of their current room on
connection — not the entire world. SET ROOM "name"mounts and tears down automatically. Switching
a player's room destroys the outgoing room's content on their screen
and replays the incoming room's snapshot. No manual cleanup.
IF GAMECONTEXT$ = "HOST" THEN
WITH ROOM "Entrance"
TILEMAP "Entrance"
TILES 0 FROM RoomTiles(0)
TILESET "GrayWorld"
META "MetaData" FROM RoomMeta(0)
TILESIZE 32, 32
SIZE 10, 10
END TILEMAP
CREATE MAP "Entrance" AS LAYER 0
END WITH
WITH ROOM "Lobby"
BOUNDARY ADD "OuterRectangle", (0,0)-(288,288) SOLID ENCLOSURE
TILEMAP "Lobby" ... END TILEMAP
CREATE MAP "Lobby" AS LAYER 0
END WITH
END IFMap-traversal builtins (MAPMETA$, FOR MAPDATA, AStar) resolve the
ambient room from the surrounding WITH ROOM context — or, outside any
block, from the caller's current ME.ROOM. ASUB CheckCollision(x, y) works identically in solo and multiplayer
with no per-context branching.
Pitfall.
UPDATE MAPissued outside itsWITH ROOMblock
broadcasts unfiltered (to every connected client, including ones in
other rooms). WrapUPDATE MAPin the sameWITH ROOMblock as theCREATE MAPit pairs with.
6.3 `WITH PLAYER n SEND/SYNC` — ship body to a player-server
Ships the block body to player n's server-side PLAYER-SERVER
interpreter (slot-N runner). Server-authoritative state changes —SET ROOM, mutating PLAYERS(n).<field>, calling HOST.<field>,
running per-player game loops — are legal inside because the
player-server is the right interpreter to make them "as that player."
The block body is hoisted at compile time into a synthetic top-level
SUB with the listed args as BYVAL parameters; auto-registered as
an event handler via CALL EVENT; the WITH site becomes an EVENT SERVER n "<anon>" (args) fire.
WITH PLAYER 2 SEND (winner_name$, score)
PRINT winner_name$ + " wins with " + STR$(score) + "!"
END WITHis rewritten by the AST builder to:
SUB __with_anon_0(winner_name$, score)
PRINT winner_name$ + " wins with " + STR$(score) + "!"
END SUB
CALL EVENT __with_anon_0(0, 0) AS "__with_anon_0"
' ... user code, at the original WITH site:
EVENT SERVER 2 "__with_anon_0" (winner_name$, score)The synthetic SUB + registration are prepended to the program's
top-level statements. Because the same .crash runs in every
interpreter, every interpreter has the SUB in its function registry by
the time the event lands.
Args are by value — they are evaluated in the caller's scope at the WITH
site and shipped as event arguments. Mutating an arg inside the body
doesn't flow back. To mutate server state, write to PLAYERS(n).<field>
or HOST.<field> directly.
6.4 `WITH CLIENT SEND/SYNC` — ship body to a client (contextually routed)
Same shape as WITH PLAYER, but routing adapts to the calling context:
- From the host: broadcasts to every connected client (
EVENT CLIENTSunder the hood). Game-start announcements, lobby chat,
anything every player should see at once. - From a player-server: fires on that player's own client only
(EVENT CLIENT ME.SLOT). Lets shared code do "tell the relevant
client" withoutIF ME.ISHOSTgating. - From a multiplayer local-client: fatal runtime error (matches
existingEVENT CLIENT*authority rules — clients don't have standing
to fire client-side handlers over the wire). - In single-player: collapses to in-process dispatch.
If you specifically want to ship to one player's renderer from the
host (not their player-server), use raw EVENT CLIENT n for now.
6.5 `SEND` vs `SYNC`
SEND is fire-and-forget: the caller continues past END WITH
immediately while the body runs remotely in its own thread. Most of
the time that's what you want — game-state events shouldn't serialize
on RPC latency.
SYNC blocks the caller until the remote dispatcher acks. The caller
yield-polls a flag on the originating NetState (pattern engine + bridge
tick keep running, so YIELD semantics stay correct). Use it when
ordering matters: "run this player-server setup, then announce the
join."
There are no return values from SYNC — SUBs don't have return values,
so neither does the SYNC variant. It's purely ack-only. If the remote
body halts with a runtime error, the operator sees that error on the
remote side; the caller learns "we're done." Disconnect during the
wait unblocks the caller via the kill-signal path.
Authority rules for SYNC (one extra restriction beyond SEND):
| Caller | WITH PLAYER n SYNC |
WITH CLIENT SYNC |
|---|---|---|
| Host | Legal | Fatal — broadcast-then-wait-all has no coherent ack |
| Player-server (own slot) | Fatal — would deadlock pattern-tick | Legal |
| Local-client | Fatal | Fatal |
A player-server calling WITH PLAYER ME.SLOT SYNC would deadlock — the
pattern-tick thread that should dispatch the body is the same thread
waiting on the ack. Use SEND for own-slot async dispatch; cross-slotSYNC from a player-server should go through EVENT HOST + a CALL HOST handler that does the SYNC on the host's behalf.
The SEND (args) clause is optional — WITH PLAYER 2 and WITH CLIENT with no args parse fine and emit zero-argument synthetic SUBs.
6.6 Why there's no `WITH HOST`
WITH HOST (any variant) deliberately doesn't exist. The downward
direction — a client or player-server forcing code execution on the
host — would break the server-authoritative model. Use EVENT HOST
from a client, or EVENT SERVER n from the host, for the legitimate
request/respond patterns.
6.7 Wire shape
WITH PLAYER n SYNCis fully in-process on the server. The host's
pending-sync map (on the sharedNetState) holds a flag the
player-server's dispatcher flips after the body completes. No new
wire frames.WITH CLIENT SYNCadds two wire bits: theclient_signalJSON
carries an optionalreply_tokenfield, and a newnet_client_signal_ackinbound JSON message carries the token back.
The server's reader task routes acks directly toNetState::deliver_sync_replywithout going through the bridge tick.
7. Rooms — replication scoping
Rooms partition the world into independently-replicated chunks.
Players are in exactly one room at a time (queryable as ME.ROOM);
content is tagged with exactly one room (or the reserved sentinel_global for content visible regardless of room).
7.1 Tagging content
| Source | Resulting tag |
|---|---|
CREATE MAP / TILEMAP inside WITH ROOM "x" |
"x" |
CREATE MAP / TILEMAP outside any WITH ROOM |
_global |
Sprite created inside WITH ROOM "x" without explicit SYNC / NOSYNC |
"x" (1.29.0 auto-tagging) |
SET SPRITE n SYNC("x") |
"x" (explicit binding; re-bindable) |
SET SPRITE n NOSYNC or client-side creation |
local-only (never broadcast) |
The renderer reconciles visibility on the fly when sprites are
re-bound: a sprite that was visible and now sits in a different room
gets disabled; one that wasn't visible and now matches the player's
room gets enabled and state-shipped.
7.2 Switching rooms
' Player walks through a doorway
SET SPRITE ME.SLOT SYNC("Lobby") ' migrate player sprite to Lobby
SET ROOM "Lobby" ' mount Lobby, tear down currentBehind the scenes: outgoing room content gets a teardown snapshot,
incoming room content gets a build-up snapshot. Both happen
atomically from the player's perspective — the renderer flips state in
a single tick.
7.3 The `_global` sentinel
A reserved room name — content tagged _global is visible regardless
of the player's current room. Use for HUDs, lobby music, system
overlays, anything cross-cutting.
The host's default outside any WITH ROOM block is _global, so
existing programs that don't use rooms behave identically to pre-1.29
— everything's visible to everyone, current behavior.
7.4 Solo mode
Rooms work identically in solo mode. WITH ROOM "level1" ... END WITH
followed by SET ROOM "level1" mounts the level1 content and tears
down everything else, with no manual destroy/recreate boilerplate.
This is the "auto-level-switching for free" benefit that motivated the
1.29.0 rooms work for solo as well as multiplayer.
8. Sprite ownership and ID spaces
Sprite ownership is first-come-first-served. The first interpreter
to enable a given sprite ID becomes its creator_slot; only that slot
may modify it. There are no preallocated per-player ranges. On
disconnect, every sprite a leaving player created is swept back into
the free pool.
Two disjoint ID spaces, transparent to the renderer:
| Range | Allocator | Purpose |
|---|---|---|
0–999 |
server-side NEXTFREESPRITE / RESERVE SPRITE |
server-authoritative user sprites (broadcast if room-tagged) |
1000–2000 |
server-side particle emit | server-authoritative particles |
32768–33767 |
client-side NEXTFREESPRITE / RESERVE SPRITE |
local-only client user sprites (never broadcast) |
33768–34768 |
client-side particle emit | local-only client particles |
The allocator picks the right range based on ctx.is_server(). The
client renderer keys sprites by raw ID, so the two namespaces coexist
in the same sprite map without collision.
9. Server vs client authority
| Responsibility | Server side (host or player-server) | Local client |
|---|---|---|
| Tick own behavior engine | Host owns the shared server engine; player-server skips (avoids N× speedup) | Yes (own engine for own sprites) |
| Sprite commands | Broadcast to all clients in the sprite's room (or _global everywhere) |
Local-only; never crosses the wire |
| Resource loads (LOADIMG/LOADFONT/TEXTTOIMAGE/LOADWAV/LOADMP3/LOADVIDEO) | Allowed — metadata flows to every interpreter; only client paints | Allowed; each client loads its own copies |
| Render text/draw (PRINT, LOCATE, CLS, COLOR, BEGIN FRAME, …) | Fatal — use WITH CLIENT SEND/SYNC or EVENT CLIENTS |
Allowed |
| Persistence (SAVETEXT, SAVEJSON, OPEN FOR OUTPUT/APPEND) | Allowed (HOST writes for shared state; PLAYER writes for per-player saves) |
Fatal — fire EVENT HOST "Save" instead |
| Custom actions | React to inbound EVENT SERVER / EVENT HOST via CALL EVENT |
Originate via EVENT SERVER "name"(args) / EVENT HOST "name"(args) |
| Input streaming (KEYDOWN/MOUSE) | Reads per-player state populated by streamed events (opt-in via CRASHNET STREAM) | Reads local SDL events directly |
10. Broadcasting and per-player perspectives
10.1 What gets broadcast
| Content | Broadcast to |
|---|---|
| Server-side sprite commands on a room-tagged sprite | All clients currently in that sprite's room |
Server-side sprite commands on a _global-tagged sprite |
All clients |
Server-side commands inside WITH CLIENT SEND/SYNC (from host) |
All clients |
Server-side commands inside WITH CLIENT SEND/SYNC (from player-server) |
That player's client only |
Non-sprite GFX issued from a player-server (outside WITH CLIENT) |
Only that one player's CrashPlayer |
| Audio commands from host | All clients |
| State sync ticks (PLAYERS roster, HOST DATA, PLAYER DATA) | All clients |
Custom signals (EVENT SERVER / EVENT HOST) |
Single recipient (originating slot's player-server, or host) |
Custom signals (EVENT CLIENT n / EVENT CLIENTS) |
Single client / all clients (from host only) |
10.2 Coordinate transformation
World position (100, 200) on Layer 1
│
├─► Player 0 (scroll 0, 0) → Screen (100, 200)
├─► Player 1 (scroll 50, 0) → Screen (50, 200)
└─► Player 2 (scroll 0, 100) → Screen (100, 100)BehaviorEngine stores world coordinates. The server transforms
coordinates for each player based on their scroll. Each client
receives screen coordinates relative to its view. This is how
per-player cameras work — there's no client-side scroll math; it's all
done server-side per-recipient.
11. Lifecycle events
11.1 Player join / leave
When a player joins or leaves, registered handlers are invoked. The
CALL site uses empty parens — the runtime supplies the player object
as the SUB's single parameter automatically.
SUB OnPlayerChange(player)
IF player.JOINED THEN
PRINT player.NAME$ + " joined in slot " + STR$(player.SLOT)
ELSE
' Player disconnected — their sprites are auto-swept by the runtime,
' no manual cleanup needed.
PRINT player.NAME$ + " left"
END IF
END SUB
CALL PLAYER OnPlayerChange() GUARD(HOST) AS "playerHandler"The typical pattern is GUARD(HOST) — the host is the authoritative
observer of join/leave events. Add SINGLE if the same logic should
run in solo (e.g., for a "self-joins-on-start" pattern).
11.2 Player object properties
The player object passed to handlers contains:
| Property | Type | Description |
|---|---|---|
SLOT |
Integer | Player slot (0-based) |
NAME$ |
String | Display name |
ISHOST |
Integer | 1 if currently host, 0 otherwise |
CONNECTED |
Integer | 1 if connected, 0 if disconnected |
JOINED |
Integer | 1 if joining, 0 if leaving |
| (custom) | per-schema | any field declared in CRASHNET PLAYER DATA |
11.3 Host change
SUB OnBecameHost()
PRINT "I am now the host!"
' Take over AI control, start host-only timers, etc.
END SUB
CALL HOST OnBecameHost() AS "hostHandler"Fires on a player when they become the new host (host migration).
11.4 Unregistering
STOP PLAYER "playerHandler"
STOP HOST "hostHandler"12. Custom events
CALL EVENT registers a handler; the event-firing statements deliver
to it. All event delivery is fire-and-forget — no return values, no
error path to the originator.
12.1 Registering handlers
SUB OnJump(x, y)
' Authoritative reaction; ME inside this SUB is the player-server slot
END SUB
CALL EVENT OnJump(0, 0) AS "Jump"
SUB RefreshScore()
WITH LAYER 1
CLS -1
PRINT "Score: " + STR$(ME.score)
END WITH
END SUB
CALL EVENT RefreshScore() GUARD(CLIENT, SINGLE) AS "RefreshScore"GUARD applies to event handlers exactly like SUB definitions.
12.2 Firing events
| From | Statement | Lands on |
|---|---|---|
| Local client | EVENT SERVER "name" (args) |
That client's own player-server |
| Local client | EVENT HOST "name" (args) |
The host |
| Host | EVENT SERVER n "name" (args) |
Player-server slot n |
| Host | EVENT SERVERS "name" (args) |
Every player-server |
| Host | EVENT CLIENT n "name" (args) |
Client slot n |
| Host | EVENT CLIENTS "name" (args) |
Every client |
| Player-server | EVENT CLIENT "name" (args) |
That player's own client (no explicit slot needed) |
| Player-server | EVENT HOST "name" (args) |
The host |
' Client-side: forward discrete actions
IF ME.ISCLIENT THEN
IF KEYDOWN("SPACE") THEN
EVENT SERVER "Jump" (MOUSEX, MOUSEY) ' → own player-server
EVENT HOST "Build" ("barracks", x, y) ' → host
END IF
END IFArgs may be Integer, Float, String, Coordinate, Mutation (serialized as
a coordinate sequence), Array (1D), or Object of those types. Function
values, multi-dim arrays, and Range fail loudly. Arity must match the
registered handler exactly — mismatch is fatal.
For discrete actions (jump, shoot, build), prefer EVENT SERVER
/ EVENT HOST over input streaming — it's far cheaper and more
explicit.
12.3 Solo collapse
In solo mode (1.28.0+), every EVENT * statement collapses to
in-process dispatch — the matching handler is looked up and invoked
synchronously. This means the same .bas runs unmodified in solo and
multiplayer; you don't need IF GAMECONTEXT$ <> "SINGLE" THEN EVENT HOST ... gating.
13. PLAYER DATA / HOST DATA as a synchronization mechanism
PLAYER DATA and HOST DATA fields are replicated on the tick loop —
writes on one interpreter propagate to every other interpreter on the
next tick. That's the foundation for any cross-context handshake.
The lobby-room example uses two PLAYER DATA fields plus one HOST DATA
flag, plus a local-only ClientReady boolean:
CRASHNET
PLAYER DATA
resourcesLoaded AS integer ' set by the joining player-server
END PLAYER DATA
HOST DATA
hostLoaded AS integer ' set by the host once world is built
END HOST DATA
END CRASHNET
ClientReady = FALSE ' local-only per-interpreter flagSet-once writes after the relevant setup:
IF GAMECONTEXT$ = "PLAYER" THEN ME.resourcesLoaded = TRUE
IF GAMECONTEXT$ = "HOST" THEN HOST.hostLoaded = TRUELocal-only flag at the bottom:
ClientReady = TRUE
WHILE TRUE
YIELD
WENDInside the join handler, all three are used together:
SUB OnPlayerJoin(p)
WITH PLAYER p.slot SEND (p)
' We're now on the player-server. Wait until both world and
' our own resource loads are flagged ready.
WHILE NOT ME.resourcesLoaded OR NOT HOST.hostLoaded
YIELD
WEND
WITH CLIENT SYNC
' We're now on the player's client. Wait until its
' top-level code finished (read in the client's scope
' because this body is hoisted to the client).
WHILE NOT ClientReady
YIELD
WEND
' ... defensive load + welcome effect ...
END WITH
ENABLE SPRITE p.slot
SET SPRITE p.slot SYNC("Entrance")
SET ROOM "Entrance"
CALL PlayerGameLoop() GUARD(PLAYER)
END WITH
END SUB
CALL PLAYER OnPlayerJoin() GUARD(HOST) AS "PlayerHandler"The key insight: ClientReady is read in the interpreter the body
is hoisted into. Inside WITH CLIENT SYNC, the read happens on the
client, so it sees the client's copy of the flag. Same identifier,
different scope per body.
By contrast, ME.resourcesLoaded and HOST.hostLoaded are replicated
fields — reads see the latest tick-synced value regardless of where the
read happens.
14. Input streaming
By default no client input is forwarded to the server (bandwidth-first).
Server-side KEYDOWN, MOUSEX, MOUSEDOWN etc. silently return empty
/ zero unless the corresponding category is opted in via the CRASHNET
block.
CRASHNET
STREAM KEYBOARD ' KeyDown / KeyUp events to server
STREAM MOUSE BUTTONS ' Clicks only (recommended — no movement spam)
' STREAM MOUSE ' Position + buttons (heavy on networks)
END CRASHNET| Read site | Reads from |
|---|---|
| Single-player or local client | Global SDL state — always works |
| Server-side host or player-server | Per-player state, populated only from streamed events |
For occasional discrete actions (jump, shoot, build), prefer EVENT SERVER "name"(args) over input streaming — see §12.
15. Disconnect handling
When a player disconnects:
- Sprites swept: every sprite the leaving player created is
removed from the shared sprite map and freed back into the
allocator pool. - Event queued:
PlayerEvent::Leftis appended to the broadcast
queue. - Handlers invoked: every player's
CALL PLAYERhandler fires
withplayer.JOINED = 0(subject to the handler'sGUARD). - Pending SYNCs unblocked: any
WITH PLAYER/WITH CLIENT SYNC
waiting on this player is unblocked via the kill-signal path. - No host action needed: cleanup is automatic — there are no
preallocated ranges to scan.
16. Patterns and antipatterns
Do
- Put resource loads at top level, unguarded. Every interpreter
needs the metadata. - Use
GAMECONTEXT$for block-level dispatch rather than chainedIF ME.ISHOST OR ...conditionals. - Use
GUARD(HOST, SINGLE)/GUARD(CLIENT, SINGLE)when a handler
should fire in both multiplayer and solo. - Wrap host-side room setup in
WITH ROOMso replication and
late-join replay work automatically. - Use
WITH PLAYER nfor state changes that should be made "as
that player" (SET ROOM, writes toPLAYERS(n).<field>, calling
per-player loops). - Use
WITH CLIENTfor rendering dispatch and per-player visual
effects. Trust the contextual routing — from host it broadcasts,
from player-server it targets that player's own client. - Use
SYNCwhen ordering matters; default toSENDotherwise. - Use PLAYER DATA / HOST DATA flags for cross-context handshakes
(the resources-ready pattern).
Don't
- Don't wrap resource loads in
IF ME.ISCLIENT THEN— starves the
server of dimension data, breaks collision. - Don't use
GUARD(SINGLE)as a call-once lock — that's the old
meaning. UseGUARD(ONCE).SINGLEnow means "solo only." - Don't
WITH PLAYER ME.SLOT SYNCfrom a player-server — it
deadlocks. UseSEND, or go throughEVENT HOST+ aCALL HOST
handler. - Don't expect arg mutation inside a hoisted body to flow back —
args are BYVAL. Write toPLAYERS(n).<field>orHOST.<field>
instead. - Don't try
WITH HOST— it deliberately doesn't exist. UseEVENT HOSTfrom clients. - Don't issue raw
PRINT/LOCATE/CLS/BEGIN FRAMEfrom a
server-side interpreter — fatal since 1.27.0. Route throughWITH CLIENTorEVENT CLIENTS "Name"+ aGUARD(CLIENT, SINGLE)
handler. - Don't issue
SAVETEXT/SAVEJSON/OPEN FOR OUTPUTfrom a
client — fatal. FireEVENT HOST "Save"and have aGUARD(HOST, SINGLE)handler do the write. - Don't
UPDATE MAPoutside itsWITH ROOMblock — broadcasts
unfiltered. Wrap UPDATE in the same room block as the CREATE.
17. Authentication
CrashNet uses JWT tokens for player authentication, compatible with the
CrashBasic Arcade auth system.
Token requirements
- Algorithm: HS256
- Issuer:
crashbasic-arcade - Audience:
crashbasic-client - Required claim:
features.crashnetMultiplayer = true
Token payload
{
"sub": "user-id",
"username": "player1",
"displayName": "Player One",
"features": {
"crashnetMultiplayer": true
},
"iat": 1234567890,
"exp": 1234567890,
"iss": "crashbasic-arcade",
"aud": "crashbasic-client"
}Client connection
ws://host:port?token=<JWT>&name=<fallback_name>Both token and name are percent-encoded by the client to handle
special characters safely.
18. Running the server
Command line
# With JWT authentication (production)
CRASHNET_JWT_SECRET="your-secret" cargo run --bin crashnet-server -- game_dir
# Simulation mode (local testing, no auth required)
cargo run --bin crashnet-server -- -s game_dir
# Full options
cargo run --bin crashnet-server -- \
--addr 0.0.0.0:9005 \
-m 8 \
--jwt-secret "secret" \
game_dir
# Serve WASM player + game from one process
cargo run --bin crashnet-server -- -p ./web -g game_dirConnecting with CrashPlayer
crashplayer --remote ws://localhost:9005 --name "Player1"
crashplayer --remote ws://host:9005 --token <JWT>Server options
| Option | Env Variable | Description |
|---|---|---|
-g, --game-dir <DIR> |
— | Game directory (alternative to positional arg) |
-p, --player-dir <DIR> |
CRASHNET_WEB_DIR |
Serve WASM player from this directory |
-m, --max-players <N> |
CRASHNET_MAX_PLAYERS |
Maximum players (default: 4) |
-s, --simulation |
CRASHNET_SIMULATION |
Skip JWT validation (testing) |
--jwt-secret <S> |
CRASHNET_JWT_SECRET |
JWT secret for HS256 |
-a, --addr <ADDR> |
CRASHCORE_ADDR |
Listen address (default: 127.0.0.1:9005) |
19. Worked example: lobby-room game
The example used throughout this doc. A small two-room game (Entrance
- Lobby) with a doorway transition, late-join handshake, and per-player
HUD updates. Drops into a singlemain.bas.
CRASHNET
STREAM KEYBOARD
PLAYER DATA
resourcesLoaded AS integer
END PLAYER DATA
HOST DATA
hostLoaded AS integer
END HOST DATA
TICK RATE 20
END CRASHNET
ClientReady = FALSE
IF GAMECONTEXT$ = "SINGLE" THEN
INPUT "What is your name?", PlayerName
PlayerName = PlayerName + " Joined"
ELSE
PlayerName = me.name$ + " Joined"
END IF
DIM RoomTiles(0 to 1, 0 to 9, 0 to 9) AS integer
DIM RoomMeta(0 to 1, 0 to 9, 0 to 9) AS string
SUB LoadResources()
NEWIMAGE 32,32,7,"GrayWorld" AS SPRITESHEET(32,32)
NEWIMAGE 32,32,3,"Green"
TEXTTOIMAGE "👻", "player", 16
TEXTTOIMAGE PlayerName, "PlayerName", 8
END SUB
SCREEN 320, 320
CREATE LAYER 1,320,320
CALL LoadResources()
FOR i = 0 to 9
RoomTiles(0,0,i) = 1
RoomTiles(0,i,0) = 1
RoomTiles(0,9,i) = 1
RoomTiles(0,i,9) = 1
RoomTiles(1,0,i) = 1
RoomTiles(1,i,0) = 1
RoomTiles(1,9,i) = 1
RoomTiles(1,i,9) = 1
RoomTiles(0,3,0) = 0
RoomMeta(0,3,0) = "Lobby"
RoomTiles(1,5,0) = 0
RoomMeta(1,5,0) = "Entrance"
NEXT i
BEHAVIOR
"FloatUp":
BMOVE 0, -1
BALPHA 1 to 0 DURATION 100
"MoveLeft":
BMOVE -1,0
"MoveRight":
BMOVE 1, 0
"MoveUp":
BMOVE 0,-1
"MoveDown":
BMOVE 0, 1
END BEHAVIOR
PATTERN "FloatUp"
"FloatUp" WHEN 1=1
END PATTERN
PATTERN "PlayerSprite"
"MoveUp" WHEN KEYDOWN("UP")
"MoveLeft" WHEN KEYDOWN("LEFT")
"MoveRight" WHEN KEYDOWN("RIGHT")
"MoveDown" WHEN KEYDOWN("DOWN")
END PATTERN
IF GAMECONTEXT$ = "HOST" THEN
WITH ROOM "Entrance"
TILEMAP "Entrance"
TILES 0 FROM RoomTiles(0)
TILESET "GrayWorld"
META "MetaData" FROM RoomMeta(0)
TILESIZE 32,32
SIZE 10, 10
END TILEMAP
CREATE MAP "Entrance" AS LAYER 0
END WITH
WITH ROOM "Lobby"
BOUNDARY ADD "OuterRectangle", (0,0)-(320-32,320-32) SOLID ENCLOSURE
TILEMAP "Lobby"
BASEMAP "lobby.map.json"
TILESET "GrayWorld"
META "MetaData" FROM RoomMeta(1)
TILESIZE 32,32
SIZE 10, 10
END TILEMAP
CREATE MAP "Lobby" AS LAYER 0
END WITH
END IF
SUB PlayerGameLoop()
MetaAtThisSpot = ""
LastTileX = -1
LastTileY = -1
WHILE NOT KEYDOWN("Q")
CurrentTileX = SPRITEX(ME.SLOT, .5) \ 32
CurrentTileY = SPRITEY(ME.SLOT, .5) \ 32
MetaAtThisSpot = MAPMETA$(CurrentTileX, CurrentTileY)
IF CurrentTileX <> LastTileX OR CurrentTileY <> LastTileY THEN
WITH CLIENT SEND(MetaAtThisSpot, CurrentTileX, CurrentTileY)
WITH LAYER 1
CLS -1
PRINT MetaAtThisSpot, CurrentTileX, CurrentTileY
END WITH
END WITH
END IF
LastTileX = CurrentTileX
LastTileY = CurrentTileY
IF keydown("S") THEN
COOLDOWN 20
WITH CLIENT SYNC
WITH LAYER 1
CLS 0 EFFECT(DISSOLVE) SPEED 30
WHILE TRANSITION() <> ""
YIELD
WEND
END WITH
END WITH
SET SPRITE ME.SLOT SYNC("Lobby")
SET ROOM "Lobby"
WITH CLIENT SYNC
WITH LAYER 1
CLS -1 EFFECT(DISSOLVE) SPEED 30
WHILE TRANSITION() <> ""
YIELD
WEND
END WITH
END WITH
END COOLDOWN
END IF
YIELD
WEND
BOOM TRUE, "Early termination of player server game loop"
END SUB
IF GAMECONTEXT$ = "PLAYER" THEN
ME.resourcesLoaded = TRUE
END IF
IF GAMECONTEXT$ = "HOST" THEN
HOST.hostLoaded = TRUE
END IF
SUB OnPlayerJoin(p)
WITH PLAYER p.slot SEND (p)
WHILE NOT ME.resourcesLoaded OR NOT HOST.hostLoaded
YIELD
WEND
WITH CLIENT SYNC
WHILE NOT ClientReady
YIELD
WEND
CALL LoadResources()
WHILE NOT ME.resourcesLoaded OR NOT HOST.hostLoaded
YIELD
WEND
WITH LAYER 1
CLS -1
END WITH
EMITPARTICLE (100,100), "PlayerName", "sers", "FloatUp", 200 LAYER 1
END WITH
ENABLE SPRITE p.slot
SET SPRITE p.slot, 32, 32, "player", 0
SET SPRITE p.slot TAG "Player"
SET SPRITE p.slot SOLID SPRITES("Player")
SET SPRITE p.slot SYNC("Entrance")
SET SPRITE p.slot PATTERN "PlayerSprite"
SHOW SPRITE p.slot
SET ROOM "Entrance"
CALL PlayerGameLoop() GUARD(PLAYER)
END WITH
END SUB
CALL PLAYER OnPlayerJoin() GUARD(HOST) AS "PlayerHandler"
ClientReady = TRUE
WHILE TRUE
YIELD
WEND
END19.1 Flow walkthrough
Reading the example end-to-end with the hoisting model in mind:
- Startup, every interpreter. Parse the CRASHNET block, allocate
RoomTiles/RoomMeta, defineLoadResources/PlayerGameLoop
/OnPlayerJoin, register BEHAVIOR + PATTERN entries, runSCREEN,CREATE LAYER 1,CALL LoadResources()(safe everywhere since
1.27), seed the room arrays. - Host-only world setup.
IF GAMECONTEXT$ = "HOST"builds two
rooms viaWITH ROOM "Entrance"andWITH ROOM "Lobby", tagging
the tilemaps + boundary appropriately. - Per-context ready flags. Player-servers set
ME.resourcesLoaded = TRUE; host setsHOST.hostLoaded = TRUE. These propagate via
tick. - Join-handler registration.
CALL PLAYER OnPlayerJoin() GUARD(HOST)makes the host the sole observer of join events. - Bottom of file, every interpreter.
ClientReady = TRUE; WHILE TRUE YIELD WEND— top-level work done, enter idle loop. - A player connects.
- Host's
CALL PLAYERfiresOnPlayerJoin(p)on the host. - Host runs
WITH PLAYER p.slot SEND (p)— body shipped to that
player's player-server. - Player-server waits for
ME.resourcesLoadedANDHOST.hostLoaded. - Player-server runs
WITH CLIENT SYNC— body shipped to that
player's client; player-server blocks on ack. - Client waits for its own
ClientReady, callsLoadResources
defensively, emits a welcome particle on the HUD layer. - Client acks; player-server resumes.
- Player-server enables the player sprite, binds it to
"Entrance", callsSET ROOM "Entrance", callsPlayerGameLoop() GUARD(PLAYER). PlayerGameLoopruns on the player-server; sprite pattern
triggers come from the client viaSTREAM KEYBOARD.- On door transitions,
WITH CLIENT SYNCblocks the player-server
on a dissolve rendered by the client; meanwhile the player-server
re-binds the player sprite to"Lobby"and mounts the new room.
- Host's
The same source file expresses host setup, the join handshake, the
per-player main loop, client-side rendering choreography, and a
solo-mode fallback — by leaning on hoisting + explicit context
dispatch rather than maintaining four separate codebases.
20. Network protocol
Outbound (server → client)
| Message | Purpose |
|---|---|
net_joined |
Confirms join with slot, room_id, max_players |
net_player_joined |
Broadcast when player joins |
net_player_left |
Broadcast when player leaves |
net_host_changed |
Broadcast when host changes |
net_state_update |
Periodic tick-based state sync |
net_room_full |
Room is full, join rejected |
net_error |
Authentication or other error |
gfx |
Graphics commands (room-filtered) |
audio |
Audio commands |
client_signal |
WITH CLIENT body fire (+ optional reply_token) |
Inbound (client → server)
| Message | Purpose |
|---|---|
input |
Player input events (KEYDOWN/UP, MOUSEMOVE/CLICK) |
net_leave |
Player leaving room |
event_server |
EVENT SERVER "name" (args) fire |
event_host |
EVENT HOST "name" (args) fire |
net_client_signal_ack |
WITH CLIENT SYNC body completion ack |
21. Module reference
| Module | Purpose |
|---|---|
crates/crashcore/src/auth.rs |
JWT validation (Authenticator, JwtClaims) |
crates/crashcore/src/net_state.rs |
Shared state (NetState, PlayerInfo, SpriteOwnership, room snapshots, pending-SYNC map) |
crates/crashcore/src/net_manager.rs |
Connection manager, tick loop, message routing |
crates/crashcore/src/player_bus.rs |
Per-player MessageBus with selective + room-filtered broadcast |
crates/crashcore/src/builtins/net.rs |
ME, PLAYERS, CRASHNET, HOST, GAMECONTEXT$ pseudo-vars |
crates/crashcore/src/eval/net.rs |
SYNC, CALL PLAYER/HOST, EVENT SERVER/HOST/CLIENT(S) |
crates/crashcore/src/eval/with.rs |
WITH LAYER / ROOM / PLAYER / CLIENT execution + hoisting |
crates/crashcore/src/ast_builder.rs |
Compile-time hoisting of WITH PLAYER / WITH CLIENT bodies |
22. Design principles
- Server authoritative. Game state lives on the server; clients
render transformed broadcasts. - Hoisting + explicit dispatch. Same
.basruns in four
contexts; top-level declarations hoist everywhere; context-specific
behavior comes fromGAMECONTEXT$/GUARD/WITH PLAYER/WITH CLIENT. Nothing magically routes itself. - Disjoint sprite ID spaces. Server (0–2000) vs client-only
(32768+) so namespaces never collide; allocator picks the right one
based on context. - Bandwidth-first defaults. Input is not streamed unless explicitly
opted in viaSTREAM; custom actions go via fire-and-forgetEVENT SERVER/EVENT HOST. - Room-scoped replication. Sprites + tilemaps + per-player
visibility are room-scoped; late-join replay is per-room; cross-room
content tags as_global. - Per-player perspectives. Independent scroll/camera per player
via server-side coordinate transformation. - Graceful disconnect. Each player's sprites are auto-swept;
pending SYNCs unblock; no host action needed. - Solo collapse. Single-player runs the same
.baswithEVENT *
andWITH PLAYER/WITH CLIENTcollapsing to in-process dispatch.GUARD(*, SINGLE)opts handlers into solo as well as their
multiplayer context.