CrashNet Multiplayer System

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

  1. Architecture overview
  2. The hoisting model
  3. The CRASHNET configuration block
  4. GAMECONTEXT$ and the four execution contexts
  5. GUARD — context restriction
  6. WITH blocks — ambient context + compile-time hoisting
  7. Rooms — replication scoping
  8. Sprite ownership and ID spaces
  9. Server vs client authority
  10. Broadcasting and per-player perspectives
  11. Lifecycle events
  12. Custom events
  13. PLAYER DATA / HOST DATA as a synchronization mechanism
  14. Input streaming
  15. Disconnect handling
  16. Patterns and antipatterns
  17. Authentication
  18. Running the server
  19. Worked example: lobby-room game
  20. Network protocol
  21. Module reference
  22. 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 one
BehaviorEngine. 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 and
PLAYERS(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 integer

After parsing, every interpreter has a ME.resourcesLoaded slot, a
HOST.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 and
PATTERN ... 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 PATTERN

Pattern 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", 16

LOADIMG / 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 THEN or
GUARD(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-side
PRINT 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 CRASHNET

PLAYER DATA fields appear on ME for the local player and on
PLAYERS(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 SELECT

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

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

Typical 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 now GUARD(ONCE). SINGLE now means "solo only."
Rename old GUARD(SINGLE) call-once uses to GUARD(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 WITH

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

  1. Outbound broadcasts are filtered. Clients in room "Entrance"
    see "Entrance" content only; clients in "Lobby" see "Lobby"
    content only. Content outside any WITH ROOM block is tagged with
    the reserved sentinel _global and visible to every client.
  2. 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.
  3. 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 IF

Map-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. A
SUB CheckCollision(x, y) works identically in solo and multiplayer
with no per-context branching.

Pitfall. UPDATE MAP issued outside its WITH ROOM block
broadcasts unfiltered (to every connected client, including ones in
other rooms). Wrap UPDATE MAP in the same WITH ROOM block as the
CREATE MAP it 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 WITH

is 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 CLIENTS under 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" without IF ME.ISHOST gating.
  • From a multiplayer local-client: fatal runtime error (matches
    existing EVENT 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-slot
SYNC 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 SYNC is fully in-process on the server. The host's
    pending-sync map (on the shared NetState) holds a flag the
    player-server's dispatcher flips after the body completes. No new
    wire frames.
  • WITH CLIENT SYNC adds two wire bits: the client_signal JSON
    carries an optional reply_token field, and a new
    net_client_signal_ack inbound JSON message carries the token back.
    The server's reader task routes acks directly to
    NetState::deliver_sync_reply without 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 current

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

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

Set-once writes after the relevant setup:

IF GAMECONTEXT$ = "PLAYER" THEN ME.resourcesLoaded = TRUE
IF GAMECONTEXT$ = "HOST"   THEN HOST.hostLoaded    = TRUE

Local-only flag at the bottom:

ClientReady = TRUE
WHILE TRUE
    YIELD
WEND

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

  1. Sprites swept: every sprite the leaving player created is
    removed from the shared sprite map and freed back into the
    allocator pool.
  2. Event queued: PlayerEvent::Left is appended to the broadcast
    queue.
  3. Handlers invoked: every player's CALL PLAYER handler fires
    with player.JOINED = 0 (subject to the handler's GUARD).
  4. Pending SYNCs unblocked: any WITH PLAYER / WITH CLIENT SYNC
    waiting on this player is unblocked via the kill-signal path.
  5. 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 chained
    IF 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 ROOM so replication and
    late-join replay work automatically.
  • Use WITH PLAYER n for state changes that should be made "as
    that player" (SET ROOM, writes to PLAYERS(n).<field>, calling
    per-player loops).
  • Use WITH CLIENT for 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 SYNC when ordering matters; default to SEND otherwise.
  • 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. Use GUARD(ONCE). SINGLE now means "solo only."
  • Don't WITH PLAYER ME.SLOT SYNC from a player-server — it
    deadlocks. Use SEND, or go through EVENT HOST + a CALL HOST
    handler.
  • Don't expect arg mutation inside a hoisted body to flow back —
    args are BYVAL. Write to PLAYERS(n).<field> or HOST.<field>
    instead.
  • Don't try WITH HOST — it deliberately doesn't exist. Use
    EVENT HOST from clients.
  • Don't issue raw PRINT / LOCATE / CLS / BEGIN FRAME from a
    server-side interpreter
    — fatal since 1.27.0. Route through WITH CLIENT or EVENT CLIENTS "Name" + a GUARD(CLIENT, SINGLE)
    handler.
  • Don't issue SAVETEXT / SAVEJSON / OPEN FOR OUTPUT from a
    client
    — fatal. Fire EVENT HOST "Save" and have a GUARD(HOST, SINGLE) handler do the write.
  • Don't UPDATE MAP outside its WITH ROOM block — 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_dir

Connecting 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 single main.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

END

19.1 Flow walkthrough

Reading the example end-to-end with the hoisting model in mind:

  1. Startup, every interpreter. Parse the CRASHNET block, allocate
    RoomTiles / RoomMeta, define LoadResources / PlayerGameLoop
    / OnPlayerJoin, register BEHAVIOR + PATTERN entries, run SCREEN,
    CREATE LAYER 1, CALL LoadResources() (safe everywhere since
    1.27), seed the room arrays.
  2. Host-only world setup. IF GAMECONTEXT$ = "HOST" builds two
    rooms via WITH ROOM "Entrance" and WITH ROOM "Lobby", tagging
    the tilemaps + boundary appropriately.
  3. Per-context ready flags. Player-servers set ME.resourcesLoaded = TRUE; host sets HOST.hostLoaded = TRUE. These propagate via
    tick.
  4. Join-handler registration. CALL PLAYER OnPlayerJoin() GUARD(HOST) makes the host the sole observer of join events.
  5. Bottom of file, every interpreter. ClientReady = TRUE; WHILE TRUE YIELD WEND — top-level work done, enter idle loop.
  6. A player connects.
    • Host's CALL PLAYER fires OnPlayerJoin(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.resourcesLoaded AND
      HOST.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, calls LoadResources
      defensively, emits a welcome particle on the HUD layer.
    • Client acks; player-server resumes.
    • Player-server enables the player sprite, binds it to
      "Entrance", calls SET ROOM "Entrance", calls
      PlayerGameLoop() GUARD(PLAYER).
    • PlayerGameLoop runs on the player-server; sprite pattern
      triggers come from the client via STREAM KEYBOARD.
    • On door transitions, WITH CLIENT SYNC blocks 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.

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

  1. Server authoritative. Game state lives on the server; clients
    render transformed broadcasts.
  2. Hoisting + explicit dispatch. Same .bas runs in four
    contexts; top-level declarations hoist everywhere; context-specific
    behavior comes from GAMECONTEXT$ / GUARD / WITH PLAYER /
    WITH CLIENT. Nothing magically routes itself.
  3. Disjoint sprite ID spaces. Server (0–2000) vs client-only
    (32768+) so namespaces never collide; allocator picks the right one
    based on context.
  4. Bandwidth-first defaults. Input is not streamed unless explicitly
    opted in via STREAM; custom actions go via fire-and-forget
    EVENT SERVER / EVENT HOST.
  5. Room-scoped replication. Sprites + tilemaps + per-player
    visibility are room-scoped; late-join replay is per-room; cross-room
    content tags as _global.
  6. Per-player perspectives. Independent scroll/camera per player
    via server-side coordinate transformation.
  7. Graceful disconnect. Each player's sprites are auto-swept;
    pending SYNCs unblock; no host action needed.
  8. Solo collapse. Single-player runs the same .bas with EVENT *
    and WITH PLAYER / WITH CLIENT collapsing to in-process dispatch.
    GUARD(*, SINGLE) opts handlers into solo as well as their
    multiplayer context.