CrashBASIC Variable Scoping
This guide explains how variable scoping works in CrashBASIC, based on the actual interpreter behavior.
Overview
CrashBASIC uses a hybrid scoping model:
- Shared globals - Variables created at the top level are accessible by all threads
- Thread-local scopes - Variables created inside functions/subs are isolated per-thread with nested scope stacks
Scope Levels
Global Scope (Top Level)
Variables assigned at the top level of your program (outside any FUNCTION or SUB) are shared globals:
' These are shared globals - visible everywhere
score = 0
playerName$ = "Player 1"
DIM inventory(10) AS STRING
SUB AddScore(points)
score = score + points ' Accesses the global "score"
END SUBKey behavior:
- Created when first assigned at depth 1 (no active function)
- Accessible from any function, sub, or thread
- Updates to these variables are visible to all threads immediately
Local Scope (Inside Functions/Subs)
Variables created inside a function or sub are thread-local:
FUNCTION Calculate(x, y)
temp = x * 2 ' Local to this function call
result = temp + y ' Also local
Calculate = result
END FUNCTIONKey behavior:
- Created when first assigned inside a function (depth > 1)
- Destroyed when the function returns
- Each thread has its own independent copy
- Cannot be accessed from outside the function
Variable Resolution
When you access a variable, CrashBASIC searches in this order:
- Pseudo-variables (getters like
TIME$,MOUSEX,RND) - Thread-local scopes (innermost to outermost)
- Shared globals
x = 100 ' Shared global
SUB Test()
PRINT x ' Prints 100 (finds global)
x = 200 ' Updates the global (found in globals)
DIM x AS INTEGER ' Creates LOCAL x, shadows global
x = 50 ' Updates local x
PRINT x ' Prints 50 (local shadows global)
END SUB
CALL Test()
PRINT x ' Prints 200 (global was updated before DIM)Variable Assignment Rules
When you assign to a variable (x = value):
- Check shared globals first - If the variable exists in globals, update it there
- Check thread-local scopes - If found in local scopes, update it there
- Create new variable - If not found anywhere:
- At global scope (depth 1): Create in shared globals
- Inside a function (depth > 1): Create in current local scope
score = 0 ' Creates shared global (at top level)
SUB IncrementScore()
score = score + 1 ' Updates existing global
bonus = 10 ' Creates LOCAL variable (inside function)
END SUBExplicit Local Variables
Use DIM inside a function to explicitly create a local variable:
DIM x AS INTEGER ' Global x
SUB Process()
DIM x AS INTEGER ' Local x - shadows global
x = 42 ' Assigns to local x
END SUB
CALL Process()
PRINT x ' Prints 0 (global x unchanged)Parameter Passing
BYREF (the default — in a SUB)
A SUB parameter is passed by reference unless you say otherwise, so changes to it affect the caller's variable:
SUB Double(n)
n = n * 2
END SUB
x = 5
CALL Double(x)
PRINT x ' Prints 10FUNCTION parameters are always BYVAL
None of the above applies to a FUNCTION. Its parameters are always passed
by value, and writing BYREF in one is an error rather than a preference:
FUNCTION Doubled(n) ' n is BYVAL, always
Doubled = n * 2
END FUNCTION
FUNCTION Bad(BYREF n) ' Error: BYREF is not allowed in a FUNCTIONThe rule exists so a function call is safe to use inside an expression: readingtotal = Doubled(x) + Doubled(y) should not leave you wondering whether x
changed. If you need to modify the caller's variable, that is what a SUB is
for.
BYVAL
Use BYVAL to pass a copy:
SUB Double(BYVAL n)
n = n * 2
END SUB
x = 5
CALL Double(x)
PRINT x ' Prints 5 (unchanged)When BYREF quietly becomes BYVAL
BYREF needs a caller's variable to write back into, so it applies only when the
argument is a plain variable. Anything else has no home to write to and is
passed by value no matter how the parameter was declared — a literal, an
expression, an array element (rooms(1)), or the result of a function:
SUB Modify(n)
n = 999
END SUB
CALL Modify(42) ' A literal has no variable to write back toReference Types: Arrays, Objects, and Slices
Scalars (INTEGER, FLOAT, STRING, COORDINATE) are value types — copying one
copies its contents. Arrays and objects are reference types — a variable
holds a handle to shared storage, not the data itself. This changes what
assignment and parameter passing mean for them.
Assignment aliases, it doesn't copy
DIM a(3) AS INTEGER
a(0) = 10
b = a ' b is a second NAME for the same array
b(0) = 99
PRINT a(0) ' 99 — one array, two namesTo get an independent copy, use CLONE (a deep copy — nested arrays/objects
included):
b = CLONE(a) ' a separate array
b(0) = 99
PRINT a(0) ' 10 — untouchedBYREF vs BYVAL for reference types
The default (BYREF) and explicit BYVAL behave the way their names promise —
but it's worth being precise about element writes versus reassigning the
whole parameter:
SUB FillByRef(arr) ' BYREF (default)
arr(0) = 99 ' reaches the caller's array
END SUB
SUB FillByVal(BYVAL arr) ' explicit BYVAL
arr(0) = 99 ' local only — caller's array is untouched
END SUB
DIM data(3) AS INTEGER
data(0) = 1
CALL FillByRef(data)
PRINT data(0) ' 99 — BYREF shares storage
data(0) = 1
CALL FillByVal(data)
PRINT data(0) ' 1 — BYVAL handed the sub its own deep copy| Element write through the parameter | Reassigning the whole parameter | |
|---|---|---|
| BYREF (default) | Reaches the caller's array (shared storage) | Reaches the caller |
| BYVAL | Local only — the callee got a deep copy | Local only |
BYVAL means BYVAL. An explicit
BYVALarray or object parameter is a
genuinely independent deep copy — mutating its elements cannot leak back to
the caller. (This is true even though plain=assignment aliases; the
copy happens at the call boundary.)
Slices are shared-storage views
Indexing a multi-dimensional array with fewer indices than its rank yields a
slice — a view that shares the parent's storage (see the Data Types guide
for the full rules). A slice behaves like any other array reference:
DIM Rooms(2, 9, 9) AS INTEGER
room = Rooms(1) ' a 2D view aliasing Rooms[1, :, :]
room(4, 3) = 7 ' writes straight through to Rooms(1, 4, 3)One subtlety where this meets parameter passing: a slice expression likeRooms(1) isn't a plain variable, so handing it to a BYREF parameter
still passes the view — and because the view shares the parent's storage,
writes through the parameter reach the parent:
SUB FillCorner(grid) ' BYREF
grid(0, 0) = 5
END SUB
CALL FillCorner(Rooms(1)) ' Rooms(1, 0, 0) becomes 5If you want the sub to work on a private copy of the slice instead, declare
the parameter BYVAL (which deep-copies it) or pass CLONE(Rooms(1)).
Constants
Use CONST to create read-only variables:
CONST PI = 3.14159
CONST APP_NAME$ = "My Game"
PI = 3 ' ERROR: Cannot assign to constantConstants follow the same scoping rules as regular variables:
- At top level: shared global constant
- Inside function: local constant (destroyed when function returns)
Thread Safety
CrashBASIC is designed for multi-threaded game loops:
score = 0 ' Shared global
SUB PlayerThread()
localData = 0 ' Thread-local
DO
localData = localData + 1
score = score + 1 ' Safe: atomic update to shared global
YIELD
LOOP
END SUB
CALL THREAD PlayerThreadShared globals:
- Protected by RwLock for thread-safe access
- Read/write operations are atomic at the variable level
Thread-local variables:
- Each thread has its own scope stack
- No locking needed - complete isolation
Namespaces
A NAMESPACE ... END NAMESPACE block groups variables, functions, and subs under a named scope. Everything declared inside a namespace is addressable from outside with Namespace.Member syntax — variables, subs, and functions alike.
NAMESPACE Player
health = 100 ' Stored internally as PLAYER.HEALTH
END NAMESPACE
PRINT Player.health ' 100 — reads work from anywhere
Player.health = 75 ' Writes work from anywhere too
PRINT Player.health ' 75Variable resolution inside a namespace
Inside NAMESPACE Player:
- A bare name like
healthresolves to the namespaced variable (PLAYER.HEALTH) if one exists, otherwise falls back to a shared global. - Writing
health = 75updates the namespaced variable; it does not leak into globals. - An explicit
Player.healthalso works and always refers to the namespaced variable.
globalScore = 0
NAMESPACE Player
health = 100
SUB Hurt(amount)
health = health - amount ' writes PLAYER.HEALTH
globalScore = globalScore - 1 ' reads/writes the shared global
END SUB
END NAMESPACESubs and functions in namespaces
Subs and functions declared inside a namespace live under the same prefix. Call them from outside with Namespace.Name(args):
NAMESPACE Player
health = 100
SUB TakeDamage(amount)
health = health - amount
IF health < 0 THEN health = 0
END SUB
FUNCTION IsAlive()
IsAlive = health > 0
END FUNCTION
END NAMESPACE
CALL Player.TakeDamage(25)
PRINT Player.health ' 75
IF Player.IsAlive() THEN PRINT "Still kicking"Inside the namespace, sibling subs/functions can be called by bare name (TakeDamage resolves to Player.TakeDamage). From outside, the fully qualified name is required.
CALL RENDER, CALL TIMER, CALL THREAD, and CALL EVENT all accept namespaced sub names the same way:
CALL RENDER Player.Draw() AS "player_render"
CALL TIMER Player.Regen() EVERY 2 SECONDS AS "player_regen"Cross-namespace access
Variables in one namespace can be read and written from inside another namespace using the fully qualified path:
NAMESPACE Player
hp = 100
END NAMESPACE
NAMESPACE Combat
SUB Attack()
Player.hp = Player.hp - 10
END SUB
END NAMESPACE
CALL Combat.Attack()
PRINT Player.hp ' 90This works at the top level of a namespace block too — you don't need to be inside a sub:
NAMESPACE Combat
Player.hp = 50 ' fine; directly sets PLAYER.HP
END NAMESPACESummary of namespace access
| Action | From inside the namespace | From outside / another namespace |
|---|---|---|
Read bare var |
Resolves to the namespaced variable (falls back to global) | N/A (use Namespace.var) |
Read Namespace.var |
Works | Works |
Write var = value |
Updates the namespaced variable | N/A |
Write Namespace.var = value |
Works | Works |
| Call bare sub/function | Resolves to the namespaced sub | N/A (use Namespace.Name) |
Call Namespace.Name(args) |
Works | Works |
Pseudo-Variables
Some variables are computed on access (read-only):
| Variable | Description |
|---|---|
TIME$ |
Current time as string |
DATE$ |
Current date as string |
TIMER |
Seconds since midnight |
RND |
Random number 0-1 |
PI |
Mathematical constant |
MOUSEX, MOUSEY |
Mouse position |
INKEY$ |
Last key pressed |
These take priority over any user-defined variables with the same name.
Nested Scopes
Each function call creates a new scope level:
SUB Outer()
x = 1 ' Local to Outer
CALL Inner()
PRINT x ' Prints 1
END SUB
SUB Inner()
x = 2 ' Local to Inner (different variable)
PRINT x ' Prints 2
END SUBScope depth increases with each function call and decreases when returning.
Common Patterns
Accessing Globals from Functions
playerHealth = 100
SUB TakeDamage(amount)
playerHealth = playerHealth - amount ' Updates global
IF playerHealth < 0 THEN playerHealth = 0
END SUBAvoiding Accidental Global Modification
SUB ProcessData(data)
DIM result AS INTEGER ' Explicit local
DIM i AS INTEGER ' Explicit local
FOR i = 1 TO 10
result = result + data(i)
NEXT i
ProcessData = result
END SUBThread-Local State
SUB EnemyAI()
DIM state AS INTEGER ' Each enemy thread has its own state
DIM targetX AS INTEGER
DIM targetY AS INTEGER
DO
' AI logic using thread-local variables
YIELD
LOOP
END SUB
' Spawn multiple enemy threads - each has isolated state
FOR i = 1 TO 5
CALL THREAD EnemyAI
NEXT iSummary
| Context | Variable Creation | Visibility |
|---|---|---|
| Top level assignment | Shared global | All threads |
Top level DIM |
Shared global | All threads |
| Inside function assignment (new var) | Thread-local | Current function only |
Inside function DIM |
Thread-local | Current function only |
| Function parameter | Thread-local | Current function only |
CONST at top level |
Shared global (read-only) | All threads |
CONST in function |
Thread-local (read-only) | Current function only |