Game Patterns & Code Organization
Practical patterns for structuring CrashBASIC games, from simple single-file projects to multi-module games with scrolling, physics, and state machines.
Table of Contents
- Organizing Code with IMPORT
- Organizing Code with Namespaces
- Avoiding Magic Numbers with ENUM
- Mobile Touch Controls
- Gamepad Input
- Scene Management
- Rooms & Map Transitions
- Typed Game State
- Random Encounters
- The Game Loop
- State Machines
- Behaviors & Patterns
- Sprite Animation from Aseprite
- Sprite Rigs
- Collision Patterns
- Camera & Scrolling
- Map-Driven Entity Spawning
- Dialog & UI Overlays
- NPC Interaction
- Script Mode & Control Handoff
- Multi-Layer Architecture
- Side Scroller Patterns
- Top-Down / Adventure Patterns
- Menu & UI Patterns
- Vector Math with Coordinates
- Physics & Movement
- Managing Entities
- Save & Load
- Async & Parallel Work
- Audio Patterns
- Procedural Textures with MEMORYBUFFER
- Performance Tips
Organizing Code with IMPORT
IMPORT pulls functions, subroutines, TYPE definitions, and BEHAVIOR/PATTERN blocks from another .bas file. Imports must appear at the top of the file before any executable statements.
IMPORT "player.bas"
IMPORT "enemies.bas"
IMPORT "ui.bas"Recommended File Structure
For anything beyond a simple prototype, split your game into modules by responsibility:
my-game/
├── main.bas ' Entry point, initialization, game loop
├── player.bas ' Player TYPE, movement, abilities
├── enemies.bas ' Enemy TYPEs, AI, spawning
├── physics.bas ' Gravity, collisions, tile resolution
├── rendering.bas ' Layer setup, camera, draw routines
├── ui.bas ' HUD, menus, text overlays
├── audio.bas ' Sound loading, music management
├── levels.bas ' Level loading, tile queries, transitions
├── data.bas ' Constants, shared TYPEs, config
├── sprites/ ' Image assets
├── sounds/ ' Audio assets
└── maps/ ' Tilemap filesWhat Goes Where
main.bas — The spine. Imports everything, declares globals, runs initialization, and starts the game loop. Keep it short.
IMPORT "data.bas"
IMPORT "player.bas"
IMPORT "enemies.bas"
IMPORT "physics.bas"
IMPORT "rendering.bas"
IMPORT "ui.bas"
IMPORT "audio.bas"
IMPORT "levels.bas"
DIM player AS Player
DIM currentLevel AS INTEGER
CALL Initialize()data.bas — Shared TYPE definitions and constants. No executable code. Every other module can rely on these definitions.
' data.bas - Shared types and constants
CONST SCREEN_W = 320
CONST SCREEN_H = 240
CONST TILE_SIZE = 16
CONST GRAVITY = 0.5
CONST MAX_ENEMIES = 20
TYPE Player
x AS SINGLE
y AS SINGLE
vx AS SINGLE
vy AS SINGLE
onGround AS INTEGER
facing AS INTEGER
health AS INTEGER
maxHealth AS INTEGER
sprite AS INTEGER
END TYPE
TYPE Enemy
x AS SINGLE
y AS SINGLE
vx AS SINGLE
vy AS SINGLE
kind AS STRING
health AS INTEGER
alive AS INTEGER
sprite AS INTEGER
END TYPEplayer.bas — Everything about the player that isn't physics or rendering.
' player.bas - Player input and actions
SUB InitPlayer(BYREF p AS Player)
p.x = 48
p.y = 100
p.vx = 0
p.vy = 0
p.onGround = 0
p.facing = 1
p.health = 3
p.maxHealth = 3
p.sprite = 0
END SUB
SUB HandlePlayerInput(BYREF p AS Player)
' Horizontal movement
IF KEYDOWN("LEFT") THEN
p.vx = -2
p.facing = -1
ELSEIF KEYDOWN("RIGHT") THEN
p.vx = 2
p.facing = 1
ELSE
p.vx = 0
END IF
' Jump
IF KEYDOWN("SPACE") AND p.onGround THEN
p.vy = -8
p.onGround = 0
PLAYWAV "sfx_jump"
END IF
END SUBImport Rules
- Imports are recursive — if
main.basimportsplayer.bas, andplayer.basimportsdata.bas, thenmain.basgetsdata.bastoo (inlined throughplayer.bas). - Don't import the same file from multiple modules. The preprocessor inlines file contents, so importing
data.basfrom bothmain.basandplayer.baswould duplicate its definitions. Instead, structure your imports as a tree:main.basimports everything, and sub-modules only import what isn't already covered by their own dependencies. - Circular imports are detected and will produce an error.
- Import order matters when there are dependencies — define TYPEs before the modules that use them.
Organizing Code with Namespaces
For single-file games or when you want module-like organization without splitting into separate files, NAMESPACE blocks group related functions, subs, behaviors, patterns, and state together. Each namespace acts as a self-contained module.
Namespace as Game Modules
A typical RPG might organize around game modes, each in its own namespace:
NAMESPACE Stats
' State + the functions that manage it
END NAMESPACE
NAMESPACE Scripts
' Dialog system, NPC interaction
END NAMESPACE
NAMESPACE MapMode
' Overworld movement, scrolling, map loading
END NAMESPACE
NAMESPACE BattleMode
' Combat system, random encounters
END NAMESPACEWhat Goes in a Namespace
Each namespace should own its state and the functions that operate on it:
NAMESPACE Stats
TYPE Stats
hp AS integer
lv AS integer
gp AS integer
mp AS integer
xp AS integer
END TYPE
DIM PLAYER AS Stats
Stats.lv = 1
Stats.hp = 100
Stats.gp = 0
Stats.mp = 0
Stats.xp = 0
SUB TakeDamage(amount)
PLAY BACKGROUND "I45L1C"
Stats.hp = CLAMP(Stats.hp - amount, 0, 999)
END SUB
SUB RenderStats()
SET LAYER LAYER_STATS
CLS -1
COLOR 15, -1
LINE (SCREENWIDTH-64,1)-(SCREENWIDTH-1, 64), 15, B
WINDOW (SCREENWIDTH-64+2,2)-(SCREENWIDTH-1, 64)
PRINT "HP "; Stats.hp
PRINT "GP "; Stats.gp
PRINT "LV "; Stats.lv
END SUB
SUB Save()
SAVEJSON "saveData.json", Stats
END SUB
SUB Load()
Stats = LOADJSON("saveData.json")
END SUB
END NAMESPACECross-Namespace Access
Call functions and read/write variables across namespaces using dot syntax:
NAMESPACE BattleMode
FUNCTION RandomBattle(battleRate)
' Read from Stats namespace
damageDealt = INT(RND * (Stats.Stats.lv * 20))
' Write to Stats namespace
Stats.Stats.xp = Stats.Stats.xp + 10
' Call Stats namespace functions
CALL Stats.TakeDamage(15)
CALL Stats.RenderStats()
' Call Scripts namespace functions
answer = Scripts.DialogBox("A slime attacks!", "", ["Attack", "Run"])
END FUNCTION
END NAMESPACEBehaviors and Patterns in Namespaces
Behaviors and patterns defined inside a namespace are globally accessible (they're registered by name, not by namespace). Namespace them logically by grouping related definitions:
NAMESPACE CatsleyPatterns
BEHAVIOR
"catsleyStare":
ANIMATE "catsley", 0 FRAMERATE 16
"catsleyLaugh":
ANIMATE "catsley", 1 to 2 FRAMERATE 16
"catsleyFade":
ANIMATE "catsley", 1 to 2 FRAMERATE 16
BALPHA 1 to 0 DURATION 30
END BEHAVIOR
SUB CatsleyLaugh()
FOR SPRITES s, "catsley"
SET SPRITE s BEHAVIOR "catsleyLaugh"
NEXT SPRITE
END SUB
END NAMESPACE
' Call from anywhere:
CALL CatsleyPatterns.CatsleyLaugh()Namespace vs. IMPORT
| IMPORT | NAMESPACE | |
|---|---|---|
| File structure | One module per .bas file |
Multiple modules in one file |
| Best for | Large projects, team work, reusable libraries | Single-file games, prototypes, game jams |
| Code sharing | Import tree (watch for duplicates) | Cross-namespace dot syntax |
| State isolation | By convention (globals are shared) | By naming (Stats.hp vs MapMode.scrollState) |
You can combine both: use IMPORT to split namespaces across files for very large projects.
Avoiding Magic Numbers with ENUM
BEGIN ENUM ... END ENUM declares a group of named integer constants. Each member becomes a read-only global constant (same as CONST), auto-numbered from 0. Use it any time you'd otherwise sprinkle raw numbers through your code — game states, directions, sprite tags, message types, menu items, animation frames.
BEGIN ENUM
IDLE ' 0
WALKING ' 1
JUMPING ' 2
FALLING ' 3
DEAD ' 4
END ENUM
state = IDLE
IF state = JUMPING THEN ApplyGravity(vy)vs. the magic-number version:
state = 0
IF state = 2 THEN ApplyGravity(vy) ' what's 2? you'll have to remember.The named version is self-documenting, refuses to compile if you misspell JUMPPING, and re-orders cleanly — insert a new state at index 1 and every reference still names the right thing.
Explicit Values, Then Continue Auto-Numbering
A member can pin its own value with = expr; later members continue counting from there:
BEGIN ENUM
OK ' 0
WARN = 10 ' 10
ERROR ' 11 (continues from 10)
FATAL = 20 ' 20
END ENUMThis is the natural fit for HTTP-status-like groupings, error tiers, or any domain with conventional numeric values you want to preserve:
BEGIN ENUM
HTTP_OK = 200
HTTP_CREATED ' 201
HTTP_NOTFOUND = 404
HTTP_TEAPOT = 418
END ENUMDirection Enums
A named enum with explicit values is great for compass directions in degrees:
BEGIN ENUM Direction
NORTH = 0
EAST = 90
SOUTH = 180
WEST = 270
END ENUM
facing = NORTH
IF KEYDOWN("RIGHT") THEN facing = EAST
ROTATE SPRITE player, facingThe block name (Direction) is decorative — members are still bare global constants (NORTH, not Direction.NORTH). Pick member names that won't collide with variables or other constants. When collisions threaten, prefix: DIR_NORTH, STATE_IDLE, MSG_HELLO.
When to Use ENUM vs. CONST
| Use ENUM when... | Use CONST when... |
|---|---|
| You have a series of related integer values | You have a single value, or a handful of unrelated ones |
| Order matters or might change later | The value is semantically fixed (PI, MAX_HEALTH) |
| You want auto-numbering to do the bookkeeping | The values are floats, strings, or computed |
Placement
Declare enums at the top of your program, before any code that uses them. Members are defined when BEGIN ENUM runs, just like CONST — so a function that references IDLE before the enum block has executed will fail. Convention: put all enums in a single constants.bas or data.bas and IMPORT it first.
' main.bas
IMPORT "constants.bas" ' enums + global CONSTs declared here
IMPORT "player.bas"
IMPORT "enemies.bas"
' ...Common Uses
' Screen layer numbers
BEGIN ENUM
LAYER_SKY ' 0
LAYER_BG ' 1
LAYER_GAMEPLAY ' 2
LAYER_FG ' 3
LAYER_HUD ' 4
END ENUM
SET LAYER LAYER_HUD
CLS -1
' Tile type ids inside a tilemap array
BEGIN ENUM
TILE_EMPTY ' 0
TILE_FLOOR ' 1
TILE_WALL ' 2
TILE_DOOR ' 3
TILE_SPIKES ' 4
END ENUM
IF MAPTILE(x, y) = TILE_WALL THEN ' ...
' Menu cursor positions
BEGIN ENUM
MENU_START
MENU_CONTINUE
MENU_OPTIONS
MENU_QUIT
END ENUM
' Animation frame indexes within a strip
BEGIN ENUM
FRAME_IDLE
FRAME_WALK1
FRAME_WALK2
FRAME_JUMP
FRAME_FALL
END ENUMEvery time you'd otherwise write a literal 0, 1, 2 and have to remember what each one means — that's an enum.
What ENUM Is *Not* For
ENUM members are integers. Don't reach for it when the underlying value is a string — sprite tags, image names, sound names, room names, behavior names are all strings and want plain CONST instead:
' WRONG: tags are strings, not numbers
BEGIN ENUM
TAG_ENEMY ' 0 — but FOR SPRITES expects a string
END ENUM
FOR SPRITES s, TAG_ENEMY ' won't match anything tagged "enemy"
' RIGHT: string constants for string-valued domains
CONST TAG_ENEMY = "enemy"
CONST TAG_PROJECTILE = "projectile"
CONST SFX_JUMP = "sfx_jump"
CONST MUS_TITLE = "mus_title"
FOR SPRITES s, TAG_ENEMY
' ...
NEXT SPRITE
LOADWAV "sounds/jump.wav", SFX_JUMP
PLAYWAV SFX_JUMPRule of thumb: if you'd type quotes around the value, it's a CONST (or just a string literal). If it's a bare integer that lives in a sequence, it's an ENUM.
Mobile Touch Controls
Basic Mobile Setup
Detect mobile and configure the virtual gamepad for touch:
IF ISMOBILE() THEN
GAMEPAD ON
GAMEPAD DPAD DRAG
GAMEPAD ALPHA 0.2
GAMEPAD MAP "LEFT", "LEFT"
GAMEPAD MAP "RIGHT", "RIGHT"
GAMEPAD MAP "UP", "UP"
GAMEPAD MAP "DOWN", "DOWN"
GAMEPAD MAP "X", "SPACE"
END IFMinimal Button Layout
Most games don't need all 9 buttons. Disable the ones you don't use and reposition the rest:
IF ISMOBILE() THEN
' Hide unused buttons
GAMEPAD BUTTON "Y" OFF
GAMEPAD BUTTON "A" OFF
GAMEPAD BUTTON "B" OFF
GAMEPAD BUTTON "START" OFF
' Keep only X as action button, move it to bottom-right
GAMEPAD BUTTON "X" AT 0.9, 0.9
GAMEPAD MAP "X", "SPACE"
' Use drag-to-move instead of D-pad buttons
GAMEPAD DPAD DRAG
GAMEPAD ALPHA 0.2
GAMEPAD ON
END IFD-Pad Modes
Choose the right mode for your game:
| Mode | Syntax | Best For |
|---|---|---|
| Buttons | GAMEPAD DPAD BUTTONS |
Precise 4-direction movement (puzzle, grid) |
| Joystick | GAMEPAD DPAD JOYSTICK |
8-direction movement with visual feedback |
| Drag | GAMEPAD DPAD DRAG |
Clean screen; tap anywhere and drag to move |
Shared Input Code
With GAMEPAD MAP, your game logic uses KEYDOWN for everything — the same code works for keyboard, virtual gamepad, and physical gamepad:
' This works on desktop keyboard, mobile touch, AND physical gamepad
PATTERN "PlayerMovement"
"moveUp" WHEN KEYDOWN("UP") AND NOT scriptMode
"moveDown" WHEN KEYDOWN("DOWN") AND NOT scriptMode
"moveLeft" WHEN KEYDOWN("LEFT") AND NOT scriptMode
"moveRight" WHEN KEYDOWN("RIGHT") AND NOT scriptMode
END PATTERNGamepad Input
GAMEPAD PHYSICAL ON Comes First
Physical controller support starts off. Until GAMEPAD PHYSICAL ON runs, nothing is polled and nothing is reported — JOYCONNECTED, JOYBUTTON, JOYAXIS and RUMBLE all return nothing no matter how many pads are plugged in.
Enable it early, next to SCREEN, not lazily when you first want to read a pad:
SCREEN 320, 240
GAMEPAD PHYSICAL ONControllers are discovered by polling, so a late enable means the first pad shows up some frames after you started asking about it — which reads as "controller support is broken" and isn't.
Reading Buttons and Sticks
' Buttons: 0=A 1=B 2=X 3=Y, 4=LB 5=RB, 6=LT 7=RT, 8=SELECT 9=START, 10=L3 11=R3
IF JOYBUTTON(0) THEN CALL Jump()
' Axes: 0/1 left stick, 2/3 right stick, 4/5 d-pad, 6/7 triggers
moveX = JOYAXIS(0) ' -1..1, small deadzone already applied
aim = JOYAXIS(2)
gas = JOYAXIS(7) ' triggers read 0..1The d-pad axes (4 and 5) are derived from the d-pad buttons, so they read identically on every platform: +X right, +Y down. Analog stick Y follows the hardware convention on native desktop, so if up/down feels inverted on one platform, that is where to look.
Local Multiplayer
Every read takes an optional 0-based player index, and up to four controllers are tracked:
IF JOYCONNECTED() = 0 THEN CALL PromptForController()
FOR p = 0 TO 3
IF JOYCONNECTED(p) THEN
CALL MovePlayer(p, JOYAXIS(0, p), JOYAXIS(1, p))
IF JOYBUTTON(0, p) THEN CALL Jump(p)
END IF
NEXT pJOYCONNECTED() with no argument returns the count; with an index it returns 0 or 1 for that slot. Since any non-zero count is truthy, IF JOYCONNECTED() THEN still works for "is there at least one".
Rumble
RUMBLE STRENGTH 0.6 DURATION 200 ' both motors
RUMBLE LOW 0.9 HIGH 0.2 DURATION 400 ' heavy vs light, separately
RUMBLE PLAYER 1 STRENGTH 0.5 DURATION 150 ' a specific pad
RUMBLE STOP ' cancel nowMagnitudes are 0.0–1.0 and DURATION is milliseconds; it auto-stops when the duration elapses, so you do not need to cancel a timed rumble yourself. Drive it from ONCOLLISION for impact feedback — that is the natural pairing, since the handler already knows the impact velocity.
Keeping One Input Path
If your game is already written against KEYDOWN, you do not need a second code path. GAMEPAD MAP injects virtual key presses, so a controller drives the same code the keyboard does:
GAMEPAD MAP "A", "SPACE"
GAMEPAD MAP "START", "ESCAPE"That is also what makes the on-screen mobile gamepad and a physical pad share one implementation — see Mobile Touch Controls.
Scene Management
BEGIN SCENE / END SCENE
BEGIN SCENE snapshots the entire world, lets you mess with it, and at END SCENE puts everything back exactly how it was. That includes the rendered picture — not just sprite positions and variables, but the actual layer pixel contents, scroll offsets, palette, layer alpha, tilemap mounts, the current SCREEN resolution, and any room context. Sprites snap to their old positions, behaviors resume, and freehand LINE / PSET / PRINT pixels reappear unchanged.
This makes scenes the right tool for any "do a thing, then go back" situation: battle transitions, dungeon dives, shop overlays, cutscenes, pause screens, title-screen-over-the-game-paused, modal dialogs that have to redraw the world, anything where you want to replace the screen briefly and come back to a perfectly intact starting state. Scenes are nestable.
In addition to the world snapshot, any resource the scene creates (threads, timers, CALL RENDER callbacks, sprites, layers, images, behaviors, patterns) is tracked and cleaned up automatically at END SCENE — you don't have to manually STOP RENDER callbacks or DISABLE SPRITE things that were born inside the scene.
FUNCTION RunLevel(levelName, levelMusic)
BEGIN SCENE "level"
STOPMP3
PLAYMP3 levelMusic LOOP
' Load map, sprites, boundaries...
CREATE MAP (levelName + ".map.json", 0 to 1) AS LAYER 0 to 1
LOADIMG "sprites/blocksly.ase", "blocksly"
' Spawn entities from map metadata
FOR MAPDATA LAYER 0 (0,0)-(MAPWIDTH(0), MAPHEIGHT(0)) AS x, y, meta$, idx
IF meta$ = "P" THEN
' ... spawn player ...
END IF
NEXT MAPDATA
' Start render callbacks — auto-cleaned at END SCENE
CALL RENDER PerformScrollTracking() AS "scrollTracker"
' Game loop for this level
nextLevel = ""
WHILE nextLevel = ""
' ... handle input, collisions, dialogs ...
YIELD
WEND
' Optional transition effect on the way out
SET LAYER 3
CLS 0 EFFECT(MELT) SPEED 60
WHILE TRANSITION() <> ""
YIELD
WEND
CLS -1
END SCENE ' scrollTracker, sprites, layers, scroll, pixels — all restored
RunLevel = nextLevel
END FUNCTIONWhat Survives a Scene
When END SCENE runs (default retention mode), the engine restores:
- Sprite state — positions, velocities, behaviors, patterns, tags, metadata, visibility, solid flag
- Boundaries and active pattern instances
- Layers — dimensions, scroll, alpha, wrap, AND the rendered pixel contents
- Tilemap mounts (the textures come back without re-baking)
- Palette, current draw layer, per-layer text state
- Logical
SCREENdimensions — so you canSCREEN 800, 600inside a cutscene and snap back to your 320×240 retro game
Scene-created resources (threads, timers, CALL RENDER callbacks, sprites you spawned, layers you created) are torn down at END SCENE. You don't need matching STOP RENDER / DISABLE SPRITE calls for anything born inside the scene.
FAST Mode — Opt Out of Retention
Retention costs VRAM (~58 MB per scene level for a 1280×720 game with 8 layers). For deeply nested scenes, low-end hardware, or one-way transitions (title screens, game-over screens) where you don't need to snap back to the previous picture, opt out:
BEGIN SCENE FAST "titleScreen"
' Title screen drawing
END SCENE ' lower VRAM cost, but a brief visible re-render of the prior stateUnder FAST, layer pixel contents from freehand drawing are NOT preserved — layers get rebuilt from tilemap data only.
Resyncing Tile Arrays After END SCENE
If your BASIC code mutated a tile array inside the scene, the renderer rolls back to the pre-scene picture but your array still holds the mutated values. Call UPDATE MAP "name" after END SCENE to push the array's current contents back to the GPU:
BEGIN SCENE "editor"
tiles(5, 5) = NEW_TILE_ID
END SCENE
UPDATE MAP "level1" ' resync the restored renderer to your arrayMost games don't need this — they mutate the world outside scenes and use scenes for transient overlays. It's only relevant when the scene itself is the thing editing tiles you want to keep.
Level Loop Pattern
Chain levels by returning the next level name from each:
nextLevel = "courtyard"
WHILE nextLevel <> "quit"
nextLevel = MapMode.RunLevel(nextLevel, nextLevel)
WEND
ENDRooms & Map Transitions
Rooms partition your game into named areas — levels, dungeons, towns, shops, the boss arena. Author each area's tilemap inside a WITH ROOM "name" block at startup, then a single SET ROOM "name" later auto-mounts everything tagged for that room and tears down the old. No manual DESTROY LAYER, no juggling layer ids, no recreating maps every transition.
Rooms were designed for multiplayer (so an MMO-style server can host many concurrent areas), but single-player gets level-switching as a free byproduct: the same room-tagging machinery that gates visibility for online players makes solo level transitions one-liners.
Authoring Rooms
Wrap each level's tilemap setup in its own WITH ROOM block. Maps inside get tagged with that room name but stay dormant — they don't display until you SET ROOM to them.
WITH ROOM "town"
TILEMAP "town"
BASEMAP "town.map.json"
END TILEMAP
CREATE MAP "town" AS LAYER 1
END WITH
WITH ROOM "dungeon"
TILEMAP "dungeon"
BASEMAP "dungeon.map.json"
END TILEMAP
CREATE MAP "dungeon" AS LAYER 1
END WITH
WITH ROOM "boss"
TILEMAP "boss"
BASEMAP "boss.map.json"
END TILEMAP
CREATE MAP "boss" AS LAYER 1
END WITH
SET ROOM "town" ' kick things off in the townSwitching Rooms
SET ROOM "dungeon" ' tears down town's maps, mounts dungeon's
SET ROOM "boss" ' tears down dungeon, mounts boss
SET ROOM "" ' back to the unscoped / global viewThe renderer handles the swap. You re-spawn the player character / projectiles / room-specific entities at the new room's spawn point — the old sprites are torn down with the room.
N Rooms with a Loop
When levels share structure, author them all in one pass. A 3D tile array sliced per room scales to dozens of levels without an array-per-level bookkeeping pile:
DIM rooms(0 TO 9, 0 TO 99, 0 TO 59) AS INTEGER
' ... fill rooms() per level (procedurally, from data, however) ...
FOR i = 0 TO 9
WITH ROOM "level" + STR$(i)
TILEMAP "lvl" + STR$(i)
TILESET "tiles.png"
TILESIZE 32, 32
SIZE 100, 60
TILES 0 FROM rooms(i)
END TILEMAP
CREATE MAP "lvl" + STR$(i) AS LAYER 1
END WITH
NEXT i
SET ROOM "level0"The TILES 0 FROM rooms(i) slice is a live view — mutating rooms(3, 5, 5) and calling UPDATE MAP "lvl3" updates the renderer because the slice shares storage with rooms.
Globals That Survive Transitions
Tilemaps mounted outside any WITH ROOM block stay visible across every room — useful for a sky backdrop, a persistent HUD layer, or a parallax background that should never disappear.
' Stays visible in every room
CREATE MAP "sky" AS LAYER 0
WITH ROOM "town"
CREATE MAP "town" AS LAYER 1
END WITHRoom-Scoped Sprites
Pair SET SPRITE n SYNC("room") with rooms to scope sprite visibility too — sprites tagged with a room only appear while that room is active. Sprites without a room tag are global and visible everywhere.
WITH ROOM "dungeon"
ENABLE SPRITE 5
SET SPRITE 5, 100, 100, "skeleton" SYNC("dungeon")
END WITHCombining With Scenes
BEGIN SCENE snapshots the current room too. Pushing a battle scene from the overworld and popping it back returns you to whichever room you were in, with its maps, sprites, and scroll position intact. The two systems compose: rooms for "where in the game am I?", scenes for "step outside, then come back exactly here."
Typed Game State
TYPE + SAVEJSON for Persistent State
Define a TYPE for your game state, then use SAVEJSON / LOADJSON for one-line save/load:
NAMESPACE Stats
TYPE Stats
hp AS integer
lv AS integer
gp AS integer
mp AS integer
xp AS integer
END TYPE
DIM PLAYER AS Stats
Stats.hp = 100
Stats.lv = 1
SUB Save()
SAVEJSON "saveData.json", Stats
END SUB
SUB Load()
Stats = LOADJSON("saveData.json")
END SUB
END NAMESPACEThe TYPE fields are serialized as JSON keys. LOADJSON restores them back into the typed variable. No manual field-by-field serialization needed.
Methods: Put the Behavior on the Type
A TYPE can carry methods. Qualify a SUB or FUNCTION name with Type:: and SELF becomes an implied reference to the instance it was called on:
TYPE Enemy
hp AS INTEGER
spriteId AS INTEGER
alive AS BOOLEAN
END TYPE
SUB Enemy::Hit(amount AS INTEGER)
SELF.hp = SELF.hp - amount
IF SELF.hp <= 0 THEN CALL SELF.Die()
END SUB
SUB Enemy::Die()
SELF.alive = FALSE
DISABLE SPRITE SELF.spriteId
END SUB
FUNCTION Enemy::IsHurt() AS BOOLEAN
IsHurt = SELF.hp < 30
END FUNCTIONCall a SUB method with CALL, and a FUNCTION method in expression position. Both work on array elements, which is what makes this pay off for entity pools:
CALL enemies(i).Hit(10)
IF enemies(i).IsHurt() THEN CALL PlayLowHpSound()SELF.field mutates the instance in place — no passing the object in, no returning it, no BYREF plumbing. Compare that with the free-standing SUB Damage(BYREF e AS Enemy, amount) shape you would otherwise write, and note that a method is an ordinary sub in every other respect: same scoping, same call pipeline, and it composes with CALL THREAD obj.Method() AS "id".
A TYPE must be defined before any DIM ... AS thatType, the same rule as defining a SUB before you CALL it.
Arrays and Objects Are Reference Types — Use CLONE
This one silently corrupts save data if you don't know it. Assignment of an array or object makes an alias, not a copy:
b = a ' b and a are now the SAME storage
b.hp = 0 ' a.hp is also 0CLONE breaks the link, recursively duplicating nested arrays and objects:
checkpoint = CLONE(playerState) ' independent snapshot
playerState.hp = 0 ' checkpoint.hp is untouchedReach for it whenever you snapshot state (checkpoints, undo, "restore on death"), seed many entities from one template, or hand a caller something they should be free to mutate. For scalars there is no shared storage, so CLONE just returns an equal value and costs you nothing.
Random Encounters
DATA Blocks for Monster Tables
Use DATA blocks to define encounter tables inline, then OPEN DATA / READ to load them at runtime:
FUNCTION RandomBattle(battleRate)
IF RND * 1000 >= battleRate THEN
RandomBattle = "skipped"
EXIT FUNCTION
END IF
DATA "Monsters"
"Slime", "slime.ase", 1, 10, 0, 100
"Happy Little Skeleton", "skeleton.ase", 2, 15, 0, 100
"Derpy Ghost", "ghost.ase", 3, 20, 0, 100
END DATA
monsters = []
OPEN DATA "Monsters" AS #1
WHILE NOT EOF(1)
READ #1, monsterName, graphic, level, hp, mp, expts
PUSH monsters, {name: monsterName, graphic: graphic, level: level, hp: hp, mp: mp, expts: expts}
LOADIMG "sprites/" + graphic, monsterName
WEND
CLOSE #1
monster = CHOOSE(monsters)
' ... run battle with monster ...
END FUNCTIONKey points:
DATAblocks are named and can live anywhere in the codeOPEN DATA ... AS #nreads them like a file handle- Object literals
{key: value, ...}create objects inline CHOOSE(array)picks a random elementPUSHappends to a dynamic array
Battle Loop Pattern
Structure combat as a WHILE loop with a completion flag. Cross-namespace calls handle stats rendering and dialog:
battleComplete = FALSE
WHILE NOT battleComplete
answer = Scripts.DialogBox("A " + monster.name + " approaches...", "", ["Attack", "Magic", "Run"])
SELECT CASE UCASE$(answer)
CASE "ATTACK":
damageDealt = INT(RND * (Stats.Stats.lv * 20))
CASE "MAGIC":
damageDealt = INT(RND * (Stats.Stats.lv * 20))
Stats.Stats.mp = Stats.Stats.mp - 5
CASE "RUN":
battleComplete = TRUE
END SELECT
IF NOT battleComplete THEN
monster.hp = monster.hp - damageDealt
IF monster.hp <= 0 THEN
Stats.Stats.xp = Stats.Stats.xp + 10
battleComplete = TRUE
ELSE
CALL Stats.TakeDamage(INT(RND * (monster.level * 20)))
IF Stats.Stats.hp = 0 THEN battleComplete = TRUE
END IF
END IF
CALL Stats.RenderStats()
YIELD
WENDThe Game Loop
CrashBASIC gives you two patterns for game loops: polling with a DO/LOOP, or callback-driven with CALL RENDER.
Pattern 1: DO/LOOP (Recommended for beginners)
Simple and explicit. You control the frame timing.
SUB GameLoop()
DO
CALL HandlePlayerInput(player)
CALL UpdatePhysics(player)
CALL UpdateEnemies()
CALL UpdateCamera()
BEGIN FRAME
CALL DrawWorld()
CALL DrawEntities()
CALL DrawHUD()
END FRAME
YIELD
LOOP
END SUBYIELD is essential — it releases the thread so the engine can process input events and maintain frame timing. Without it, your game will freeze.
Pattern 2: CALL RENDER (Recommended for complex games)
Separates update logic from rendering. The render callback fires every frame automatically.
SUB Initialize()
SCREEN 320, 240
CALL LoadAssets()
CALL InitPlayer(player)
CALL LoadLevel(1)
' Start the render loop
CALL RENDER GameFrame() AS "main"
' Start a timer for fixed-rate updates
CALL TIMER GameUpdate() EVERY 16 MILLISECONDS AS "update"
END SUB
SUB GameUpdate()
CALL HandlePlayerInput(player)
CALL UpdatePhysics(player)
CALL UpdateEnemies()
CALL UpdateCamera()
END SUB
SUB GameFrame()
BEGIN FRAME
CALL DrawWorld()
CALL DrawEntities()
CALL DrawHUD()
END FRAME
END SUBWhen to Use Which
| Pattern | Best For |
|---|---|
| DO/LOOP | Small games, prototypes, linear flow |
| CALL RENDER + TIMER | Larger games, state machines, menus that need to pause/resume gameplay |
State Machines
Most games have distinct states: title screen, gameplay, pause, game over. Use SET STATE to manage transitions and INSTATE$ to exit cleanly.
How SET STATE and INSTATE$ Work
SET STATE "StateName" tells the engine to call the SUB with that name. But it doesn't automatically stop the current SUB — your code keeps running. You need to check INSTATE$ and exit your loop yourself.
INSTATE$ returns 1 if you're still in the state that launched the current SUB. Once someone calls SET STATE with a different name, INSTATE$ returns 0 — that's your signal to break out.
The standard pattern is: check INSTATE$ at the top or bottom of every DO/LOOP.
' main.bas
SUB Initialize()
SCREEN 320, 240
CALL LoadAssets()
SET STATE "TitleState"
END SUB
SUB TitleState()
DO
BEGIN FRAME
CALL DrawTitleScreen()
END FRAME
IF KEYDOWN("SPACE") THEN
SET STATE "PlayingState"
END IF
YIELD
LOOP WHILE INSTATE$
END SUB
SUB PlayingState()
CALL InitPlayer(player)
CALL LoadLevel(1)
DO
CALL HandlePlayerInput(player)
CALL UpdatePhysics(player)
CALL UpdateEnemies()
CALL UpdateCamera()
BEGIN FRAME
CALL DrawWorld()
CALL DrawEntities()
CALL DrawHUD()
END FRAME
IF player.health <= 0 THEN
SET STATE "GameOverState"
END IF
IF KEYDOWN("ESCAPE") THEN
SET STATE "PauseState"
END IF
YIELD
LOOP WHILE INSTATE$
' Cleanup when leaving this state
STOP RENDER
STOP TIMER
END SUB
SUB PauseState()
DO
BEGIN FRAME
CALL DrawWorld()
CALL DrawEntities()
CALL DrawPauseOverlay()
END FRAME
IF KEYDOWN("ESCAPE") THEN
SET STATE "PlayingState"
END IF
YIELD
LOOP WHILE INSTATE$
END SUB
SUB GameOverState()
DO
BEGIN FRAME
CLS 0
GPRINT "GAME OVER"
GPRINT "Score: " + STR$(score)
GPRINT "Press SPACE to restart"
END FRAME
IF KEYDOWN("SPACE") THEN
SET STATE "TitleState"
END IF
YIELD
LOOP WHILE INSTATE$
END SUBThe key line is LOOP WHILE INSTATE$ — as soon as any code calls SET STATE with a different name, INSTATE$ returns 0 and the loop exits naturally. The SUB then finishes, cleanup runs, and the engine calls the new state's SUB.
State Cleanup
When leaving a state, stop any active timers, threads, and render callbacks. Put cleanup code after the LOOP WHILE INSTATE$ line — it runs automatically when the state changes.
SUB CleanupGameplay()
STOP RENDER "game"
STOP TIMER "enemy_spawner"
STOP TIMER "level_timer"
END SUBBehaviors & Patterns
Behaviors and patterns are the primary way to animate and control sprites in CrashBASIC. Rather than manually updating positions, frames, and states in your game loop, you declare what a sprite should do and the engine handles the per-frame work.
Behaviors define a set of simultaneous actions: animate these frames, move at this speed, oscillate with this amplitude. A sprite can have multiple behaviors active at once.
Patterns sequence behaviors over time: play the "intro" behavior for 30 ticks, then switch to "idle" until the player presses a key, then "run". Think of a pattern as a state machine for a single sprite.
Defining Behaviors
A behavior is a named block of commands that the engine applies every tick.
' behaviors.bas - Define all game behaviors in one place
' Simple animation
BEHAVIOR "idle"
ANIMATE "hero", 0, 1 FRAMERATE 4
END BEHAVIOR
' Animation + movement
BEHAVIOR "run"
ANIMATE "hero", 2, 3, 4, 5 FRAMERATE 12
BMOVE 3, 0
END BEHAVIOR
' Jump arc using velocity and acceleration (gravity)
BEHAVIOR "jump"
ANIMATE "hero", 6 FRAMERATE 1
BVELOCITY 0, -8
BACCELERATION 0, 0.5
END BEHAVIOR
' Fade out and shrink
BEHAVIOR "die"
BALPHA 255 TO 0 DURATION 30
BSCALE 1, 1 TO 0.5, 0.5 DURATION 30
END BEHAVIOR
' Floating bob for collectibles
BEHAVIOR "float"
BOSCILLATE AMPLITUDE 0, 4 SPEED 0.05
END BEHAVIOR
' Spin in place
BEHAVIOR "spin"
BROTATE 0 TO 360 DURATION 30 ORIGIN(0.5, 0.5) LOOP
END BEHAVIORComposing with INCLUDES
Behaviors can inherit from other behaviors. This keeps definitions DRY.
' Base movement behavior
BEHAVIOR "moveRight"
BMOVE 2, 0
END BEHAVIOR
' Walk = moveRight + walk animation
BEHAVIOR "walkRight" INCLUDES("moveRight")
ANIMATE "hero", 2, 3, 4, 5 FRAMERATE 10
END BEHAVIOR
' Run = faster moveRight + run animation
BEHAVIOR "runRight" INCLUDES("moveRight")
BMOVE 4, 0 ' Overrides the 2,0 from moveRight
ANIMATE "hero", 6, 7, 8, 9 FRAMERATE 14
END BEHAVIORParameterized Behaviors
Use @param=default to make behaviors reusable with different values.
BEHAVIOR "moveToward"
BMOVE @dx=0, @dy=0
END BEHAVIOR
BEHAVIOR "patrol"
PATH @startX=0, @startY=0, @endX=100, @endY=0 SPEED 1 PINGPONG
END BEHAVIOR
' Activate with specific values:
SET SPRITE 5 BEHAVIOR "moveToward", @dx=-2, @dy=0
SET SPRITE 8 BEHAVIOR "patrol", @startX=50, @startY=200, @endX=250, @endY=200Defining Patterns
A pattern sequences behaviors into a timeline. Each step runs until its duration expires, the behavior completes, or a WHEN condition changes.
' Patrol back and forth, then pause
PATTERN "guardPatrol" LOOP
"walkRight" DURATION 120
"idle" DURATION 60
"walkLeft" DURATION 120
"idle" DURATION 60
END PATTERN
' Death sequence (not looped — runs once)
PATTERN "enemyDeath"
GHOST ' Stop colliding immediately
"hit" DURATION 10 ' Flash/recoil for 10 ticks
"die" ' Fade out (completion mode — waits for BALPHA to finish)
DISABLE ' Remove sprite
END PATTERN
' Player control — respond to input in real time
PATTERN "playerControl" LOOP
"idle" ' Default when nothing pressed
"walkRight" WHEN KEYDOWN("RIGHT")
"walkLeft" WHEN KEYDOWN("LEFT")
"jump" WHEN KEYDOWN("SPACE")
END PATTERNApplying Behaviors and Patterns to Sprites
' Set up a sprite with a pattern
ENABLE SPRITE 0
SET SPRITE 0, 100, 200, "hero", LAYER 2
SET SPRITE 0 PATTERN "playerControl"
' Set up an enemy with a patrol
ENABLE SPRITE 1
SET SPRITE 1, 300, 200, "guard", LAYER 2
SET SPRITE 1 TAG "enemy"
SET SPRITE 1 PATTERN "guardPatrol"Once assigned, the engine runs these automatically — you don't need to manually update positions or animation frames in your game loop. Your loop only handles things the behavior system can't: tile collision resolution, game logic, score tracking, state transitions.
Reacting to Behavior Events
Use EVENTSON in ANIMATE to fire events on specific frames — great for footstep sounds, attack frames, and particle spawns.
BEHAVIOR "walk"
ANIMATE "hero", 0, 1, 2, 3 EVENTSON(1, 3) FRAMERATE 10
END BEHAVIOR
' The event name is the image name (uppercased) + "_FRAME"
SUB OnFootstep(spriteId, frame)
IF frame = 1 THEN PLAYWAV "sfx_step_left"
IF frame = 3 THEN PLAYWAV "sfx_step_right"
END SUB
CALL EVENT OnFootstep() AS "HERO_FRAME"Switching Behaviors from Code
For things that depend on game logic (not just input), switch behaviors in your update loop:
SUB UpdateEnemy(spriteId AS INTEGER, health AS INTEGER)
IF health <= 0 THEN
SET SPRITE spriteId PATTERN "enemyDeath"
ELSEIF SPRITEDIST(spriteId, playerSprite) < 100 THEN
SET SPRITE spriteId BEHAVIOR "chase"
ELSE
' Pattern handles the rest (patrol)
END IF
END SUBSmooth BOSCILLATE transitions. When you switch between two behaviors that both use BOSCILLATE, the new behavior automatically picks up the previous behavior's current phase — the wave keeps flowing across the swap with no twitch. The carryover applies for one transition only; cycling through a non-oscillating behavior in between breaks the chain on purpose. See the BOSCILLATE section in the behaviors guide for details.
Where to Define Behaviors
Put all behavior and pattern definitions in a behaviors.bas file (or split into player_behaviors.bas, enemy_behaviors.bas for larger games). Import it early — behaviors must be defined before any sprite uses them.
my-game/
├── main.bas
├── behaviors.bas ' All BEHAVIOR and PATTERN definitions
├── player.bas ' Player logic (applies behaviors, handles input edge cases)
├── enemies.bas ' Enemy spawning, AI state that drives pattern switches
└── ...Sprite Animation from Aseprite
An .ase/.aseprite file is flattened into a horizontal sprite sheet on load — one frame per timeline frame — so it behaves like any sprite sheet. What it also carries, and other formats can't, is per-frame timing and per-frame slice geometry. Four clauses let you use that instead of re-entering it by hand.
One Animation Per Name with TAG
LOADIMG "hero.ase", "heroIdle" TAG("idle")
LOADIMG "hero.ase", "heroRun" TAG("run")
LOADIMG "hero.ase", "heroHurt" TAG("hurt")TAG("name") loads only that tag's frames, re-indexed from 0. So ANIMATE "heroRun", 0 TO 7 plays exactly the run cycle, with no frame-offset arithmetic and nothing to correct when you insert frames earlier in the timeline. One tag per name — load the file once per tag.
Timeline Timing with FRAMERATE OFSPRITE
BEHAVIOR "run"
ANIMATE "heroRun", 0 TO 7 FRAMERATE OFSPRITE
END BEHAVIORFRAMERATE OFSPRITE uses the per-frame durations you authored in Aseprite rather than one flat fps. If the animator held frame 3 for 200ms and flicked through 4–6, that is what plays — and re-timing it is a save in Aseprite, not a code change.
Hitboxes That Follow the Animation
BEHAVIOR "attack"
ANIMATE "heroAttack", 0 TO 5 AUTOBBOX FRAMERATE OFSPRITE
END BEHAVIORAUTOBBOX drives the sprite's bounding box from the file's slice geometry, per frame. A sword swing whose hitbox extends only on the frames where the blade is out becomes an animator decision instead of a table of magic numbers in your source.
If you want the rectangles rather than the automatic behavior, ask for them:
LOADIMG "hero.ase", "heroAttack" TAG("attack") SLICES boxes()
' boxes(frame) is a relative 0-1 Range, ready to use directly
SET SPRITE player BBOX boxes(3)The array is always dense — frames no slice covers fall back to the full frame (0,0)-(1,1) — so you can index any frame without a guard.
Splitting a Layered File into Parts
LAYERS(...) composites only the layers you name, so one artwork file can produce several sprites:
LOADIMG "tank.ase", "tankHull" LAYERS("Base", "Treads")
LOADIMG "tank.ase", "tankTurret" LAYERS("Turret")Selectors are layer names (case-insensitive) or 0-based indices from the bottom of the stack, and you can mix them. Blend modes, opacity and per-cel z-order are honored as Aseprite renders them, and an explicitly named layer composites even if its eye icon is off in the file. Combine with TAG to get one tag's frames of just those layers. This is how you build a turret that rotates independently of its hull without exporting two files.
Unknown tags, layer names and out-of-range indices are all loud errors that list what is available, so a typo tells you the answer.
Sprite Rigs
A rig is a tree of parented sprites — a torso with a head, arms and legs — plus the behaviors they run. Parenting alone gets you relative positioning; these add the parts that make it feel jointed.
RIGID: Children That Rotate With the Parent
By default a child follows its parent's position. RIGID makes it follow the parent's rotation too, orbiting the parent's origin and inheriting the spin:
SET SPRITE arm PARENT(torso) RIGIDGive each part an ORIGIN at its joint — the shoulder for an upper arm, the elbow for a forearm — so it rotates around the right point rather than its top-left corner:
SET SPRITE upperArm ORIGIN(0.1, 0.5) ' pivot at the shoulder endIK, PIN and LIMIT
' Reach: rotate the tip and its 2 ancestors so the tip lands on the target
SET SPRITE hand IK TARGET(mouseX, mouseY) LENGTH 2 BEND CCW
SET SPRITE hand IK NONE
' Ease a sprite toward a point each tick, independent of the rotation chain
SET SPRITE head PIN TARGET(lookX, lookY) MIX 0.2
SET SPRITE head PIN NONE
' Clamp a child's local offset — mechanical rigs, sliders, pistons
SET SPRITE piston LIMIT X 0, 24 AXIS X
SET SPRITE piston LIMIT NONEIK uses a FABRIK solver over the tip plus LENGTH n of its PARENT ancestors. BEND CW|CCW picks which way interior joints fold — set it, or an arm will occasionally choose the elbow direction you didn't want. MIX m blends 0..1 between the plain parented pose and the solved one, which is the knob for "reach, but not rigidly". Declaring IK auto-sets RIGID on the chain.
Save the Rig, Not the Setup Code
SAVE RIG FROM SPRITE torso TO "hero.rig.json"
LOAD RIG "hero.rig.json" ' register its behaviors/patterns
LOAD RIG "hero.rig.json" ONTO SPRITE 40 ' and instantiate onto a spriteSAVE RIG walks the whole parent/child tree and captures each node's image, relative offset, RIGID flag, frame, bbox, scale, flip, tag, origin, elevation, layer and active behavior — plus every behavior and pattern referenced anywhere in the tree, collected once. The root's absolute position is deliberately not saved: placement is per-instance, structure is the rig.
Behaviors and patterns travel on their own too:
SAVE BEHAVIOR "walkCycle" TO "walk.json"
LOAD BEHAVIOR "walk.json"Saved behaviors are written resolved — declaration-time IF/SELECT already flattened — while @param defaults and VARIANCE are preserved. Loading a name that already exists overwrites it, exactly as re-declaring the block would.
Collision Patterns
React Inside the Behavior: ONCOLLISION / AFTERCOLLISION
A behavior can handle its own collisions. Both blocks are edge-triggered — they fire once when a contact begins, not every tick it lasts — which is exactly the bookkeeping the flag patterns further down exist to hand-roll.
DIM dx AS INTEGER
dx = -1
BEHAVIOR "patrol"
BVELOCITY @vx = 0, @vy = 0
ONCOLLISION
' Runs BEFORE resolution, so impact_vx / impact_vy still hold the
' speed we struck at — the value resolution is about to wipe.
IF tag_b$ = "spikes" THEN CALL Damage(index_a, 10)
END ONCOLLISION
AFTERCOLLISION
' Runs AFTER resolution, same tick. The engine has already stopped
' us against the wall, so this is where "what now?" belongs.
dx = dx * -1
SET SPRITE index_a BEHAVIOR "patrol", @vx = dx, @vy = 0
END AFTERCOLLISION
END BEHAVIORThe scoped values. Both blocks get the contact bound as locals, describing it as it began:
| Value | Meaning |
|---|---|
index_a |
the sprite whose behavior this is |
index_b |
the other sprite, or -1 for a boundary |
tag_a$ / tag_b$ |
the two tags (tag_b$ is the boundary tag when index_b = -1) |
edge$ |
which face was contacted |
impact_vx / impact_vy |
velocity at the moment of impact, pre-resolution |
Rules worth knowing before you lean on them:
- Declare persistent variables outside the block. A handler body scopes like a
SUB, so assigning a name that was never declared at program scope creates a handler-local that vanishes on return. That is whydxabove is declared beforeBEHAVIOR. - No blocking statements.
PAUSE,DELAY,DELAY UNTILandYIELDare rejected at definition time — a handler runs on the behavior tick loop, so blocking would freeze every sprite in the game, not just this one. - Changes take effect next tick. Setting a behavior,
GHOSTorSOLIDfrom a handler is a request the engine honors on the following tick. - Boundary contacts key on the tag, not the segment. A wall built from ten
"wall"-tagged segments is one contact surface, so sliding across the seams does not re-fire. - Both sprites fire. In a sprite-to-sprite hit each side runs its own handler with itself as
index_a— including a stationary sprite that something ran into. INCLUDESstacks handlers: every inheritedONCOLLISIONruns, in composition order.
Which to reach for:
| You want | Use |
|---|---|
| React once when a contact starts | ONCOLLISION |
| Decide what to do after being stopped or bounced | AFTERCOLLISION |
| Ask "what am I overlapping right now?" | FOR COLLISIONS (below) |
Hitboxes Follow ORIGIN
ORIGIN moves a sprite's anchor inside its own frame, and the collision box moves with it — the rect is placed at position - origin x size, the same place the sprite is drawn, and BBOX fractions are measured from that drawn corner.
' Anchored by the ground under its feet. The hitbox stays on the pixels.
SET SPRITE player ORIGIN(0.5, 1.0)If you are carrying position fudges from an older version to make hitboxes line up, take them out: before 1.54.0 the collision rect ignored ORIGIN and sat origin x size away from the sprite.
Detect-Then-React
FOR COLLISIONS answers a different question from the handlers above: not "did a contact just start" but "what am I overlapping this frame". It reports every overlap, so a sprite touching two adjacent boundary tiles yields two results — and that is what this pattern is about.
The most important rule when using it: separate detection from reaction. FOR COLLISIONS can return multiple results per frame (a sprite overlapping two adjacent boundary tiles, for example). If you react immediately inside the loop — showing a dialog, changing patterns, playing sounds — you may trigger the same reaction multiple times.
Wrong — reacts inside the detection loop:
' BAD: Dialog fires once per overlapping tile
FOR COLLISIONS a, b, boundary$
IF boundary$ = "zone1" AND a = player THEN
CALL ShowDialog("You entered the zone!") ' May fire 2-3 times!
END IF
NEXT COLLISIONRight — detect first, react after:
' GOOD: Set flags during detection, act on them after
hitZone1 = FALSE
FOR COLLISIONS a, b, boundary$
IF boundary$ = "zone1" AND a = player THEN
hitZone1 = TRUE
END IF
NEXT COLLISION
' React phase — runs exactly once regardless of overlap count
IF hitZone1 AND NOT alreadyInZone1 THEN
alreadyInZone1 = TRUE
CALL ShowDialog("You entered the zone!")
END IFThis pattern works because:
- A 32px sprite can overlap two adjacent 32px boundary tiles simultaneously
FOR COLLISIONSreports each overlap as a separate result- Dialogs, pattern changes, and sound effects should happen once, not per-tile
Trigger Zones with Cooldowns
For boundaries that should trigger repeatedly but not every frame:
hitLava = FALSE
FOR COLLISIONS a, b, boundary$
IF boundary$ = "lava" AND a = player THEN hitLava = TRUE
NEXT COLLISION
IF hitLava THEN
COOLDOWN 60 ' Once per second at 60fps
playerHealth = playerHealth - 1
PLAY BACKGROUND "L16C"
END COOLDOWN
END IFOne-Shot Triggers
For events that should only happen once (cutscenes, pickups, unlocks), ONCOLLISION already is the one-shot — it fires on the tick the contact starts and stays quiet while you remain in it:
DIM checkpointActivated AS INTEGER
BEHAVIOR "playerHooks"
ONCOLLISION
IF tag_b$ = "checkpoint" AND NOT checkpointActivated THEN
checkpointActivated = TRUE
CALL SaveGame()
END IF
END ONCOLLISION
END BEHAVIORThe checkpointActivated guard is still worth keeping: leaving the trigger and walking back in is a new contact, so it fires again. Edge-triggered means once per contact, not once per game.
The equivalent with FOR COLLISIONS, when you would rather keep the logic in the game loop than in a behavior:
hitCheckpoint = FALSE
FOR COLLISIONS a, b, boundary$
IF boundary$ = "checkpoint" AND a = player THEN hitCheckpoint = TRUE
NEXT COLLISION
IF hitCheckpoint AND NOT checkpointActivated THEN
checkpointActivated = TRUE
BOUNDARY DELETE "checkpoint" ' Remove so it can't trigger again
CALL SaveGame()
CALL ShowDialog("Checkpoint reached!")
END IFScripted Movement on Collision
When a collision should trigger a scripted movement sequence (backing up from a wall, knockback, cutscene movement):
' Define the scripted sequence
PATTERN "KnockbackUp"
"moveUp" DURATION 16
BEVENT "EndKnockback"
GOTOPATTERN "PlayerMovement"
END PATTERN
' Detection phase
hitTrap = FALSE
FOR COLLISIONS a, b, boundary$
IF boundary$ = "trap" AND a = player THEN hitTrap = TRUE
NEXT COLLISION
' Reaction phase
IF hitTrap AND NOT inKnockback THEN
inKnockback = TRUE
SET SPRITE player PATTERN "KnockbackUp"
CALL ShowDialog("Ouch! A trap!")
END IFThe BEVENT "EndKnockback" at the end of the pattern triggers a CALL EVENT handler that resets the flag:
SUB EndKnockback(spriteIndex)
inKnockback = FALSE
END SUB
CALL EVENT EndKnockback() AS "EndKnockback"Boundary Edge Detection
Use SPRITETOUCHING$ for frame-precise edge detection. Edge names refer to the boundary's edge:
' Standing on something (boundary's TOP edge)
IF SPRITETOUCHING$(player, "TOP") <> "" THEN
onGround = TRUE
velocityY = 0
END IF
' Snap to floor surface
IF SPRITETOUCHING$(player, "TOP", "floor") <> "" THEN
SET SPRITE player, LOCATION(SPRITEX(player), SPRITETOUCHINGY(player, "TOP"))
END IF| Edge | Meaning | Use Case |
|---|---|---|
"TOP" |
Sprite on boundary's top | Ground/platform detection |
"BOTTOM" |
Sprite under boundary's bottom | Ceiling/head bump |
"LEFT" |
Sprite against boundary's left | Wall to the right |
"RIGHT" |
Sprite against boundary's right | Wall to the left |
Camera & Scrolling
Smooth Camera Follow with SEEK
Use CALL RENDER to run camera tracking every frame, with SEEK() for smooth interpolation toward the player:
scrollState = (0, 0)
SUB PerformScrollTracking()
' Target: center the camera on the player
scrollTo = (INT(SPRITEX(player) - SCREENWIDTH / 2), INT(SPRITEY(player) - SCREENHEIGHT / 2))
' Smooth interpolation — only move if far enough away
seekVector = SEEK(scrollState, scrollTo, 1)
IF DISTANCE(scrollState, scrollTo) > 2 THEN
scrollState = scrollState + seekVector * 2
END IF
' Apply scroll to game layers (not UI layer)
FOR i = 0 TO 1
SET LAYER i
SET SCROLL scrollState.x, scrollState.y
NEXT i
END SUB
CALL RENDER PerformScrollTracking() AS "scrollTrackingThread"Key points:
CALL RENDERruns the SUB every frame, independent of the game loopSEEK()returns a normalized direction vector — multiply by speed to control how fast the camera followsDISTANCE()check prevents jittery micro-corrections when the camera is close to the target- Apply scroll to map and sprite layers but NOT the UI layer
Map-Driven Entity Spawning
Collision You Drew in the Editor
Before hand-writing a wall of BOUNDARY ADD calls, check whether you can draw them instead. The IDE's map editor exports collision regions to JSON, and one statement registers every one:
BOUNDARY ADD FROM("level1.boundaries.json")Each entry carries its tag, rect, and SOLID/ENCLOSURE/SLOPE/layer attributes, and is registered exactly as the literal statement would — including under the current WITH ROOM scope. It fails loudly: a missing file, malformed JSON, or a nonsensical entry halts rather than registering a partial set, so you never debug a level that is half-solid.
Using FOR MAPDATA to Place Everything
Use tile metadata in your map editor to define where entities spawn. FOR MAPDATA reads the metadata and tile index for each cell:
playerSpriteIndex = -1
FOR MAPDATA LAYER 0 (0,0)-(MAPWIDTH(0), MAPHEIGHT(0)) AS x, y, meta$, tileIndex
' Player spawn point
IF meta$ = "P" THEN
playerSpriteIndex = NEXTFREESPRITE()
ENABLE SPRITE playerSpriteIndex
SET SPRITE playerSpriteIndex, x*32, y*32, "player" LAYER 1
SET SPRITE playerSpriteIndex SOLID
SET SPRITE playerSpriteIndex PATTERN "PlayerMovement"
SHOW SPRITE playerSpriteIndex
' NPCs by metadata tag
ELSEIF meta$ = "K" THEN
newSprite = NEXTFREESPRITE()
ENABLE SPRITE newSprite
SET SPRITE newSprite, x*32, y*32, "king", 0 LAYER 1
SET SPRITE newSprite SOLID
SET SPRITE newSprite TAG "king"
SHOW SPRITE newSprite
END IF
' Wall boundaries from tile index (skip empty tiles and floor)
IF tileIndex > 0 AND tileIndex <> 10 THEN
BOUNDARY ADD "walls", (x*32,y*32)-(x*32+31, y*32+31) SOLID
END IF
' Trigger zones from metadata
IF meta$ = "1" THEN
BOUNDARY ADD "zone1", (x*32,y*32)-(x*32+31, y*32+31)
END IF
NEXT MAPDATAKey points:
ENABLE SPRITEbeforeSET SPRITEreserves the slot soNEXTFREESPRITE()won't return the same ID- Use
TAGfor sprite-type identification (used bySPRITECOLLISIONandFOR SPRITES) - Separate wall boundaries (
SOLID) from trigger zones (noSOLIDkeyword) - Metadata strings can be any length —
"P","spawn_player","door_north"all work
Metadata as Zone Tags
Use different metadata values to create distinct trigger zones without tile-index hacking:
' In the map editor, paint metadata on tiles:
' "1" = zone 1 trigger
' "2" = zone 2 trigger
' "shop" = shop entrance
IF meta$ = "1" THEN
BOUNDARY ADD "1", (x*32,y*32)-(x*32+31, y*32+31)
ELSEIF meta$ = "shop" THEN
BOUNDARY ADD "shop", (x*32,y*32)-(x*32+31, y*32+31)
END IFThen detect in the game loop using the detect-then-react pattern:
hitShop = FALSE
FOR COLLISIONS a, b, boundary$
IF boundary$ = "shop" AND a = player THEN hitShop = TRUE
NEXT COLLISION
IF hitShop AND NOT inShop THEN
inShop = TRUE
CALL OpenShop()
END IFDialog & UI Overlays
Typewriter Dialog Box
A reusable dialog SUB with typewriter text, tick sound, and two-press dismiss (one to finish typing, one to close):
SUB DialogBox(textToSay, whoSaidIt)
SET LAYER 2
WINDOW (32, SCREENHEIGHT-64)-(SCREENWIDTH-32, SCREENHEIGHT-2)
LINE (32, SCREENHEIGHT-64)-(SCREENWIDTH-32, SCREENHEIGHT-2), 7, B
PRINT whoSaidIt; ": ";
PRINT WORDS RATE(40) textToSay
' Wait for typewriter to finish (with tick sound)
WHILE TYPING()
COOLDOWN 2
PLAY BACKGROUND "L16A"
END COOLDOWN
YIELD
WEND
PAUSE ' Wait for keypress to dismiss
CLS -1 ' Clear the UI layer
PAUSE ' Eat the keypress so it doesn't leak into gameplay
END SUBKey points:
PRINT WORDSwraps text at word boundaries within theWINDOWRATE(40)controls typewriter speed (characters per second)TYPING()returns true while the typewriter effect is activeCOOLDOWN 2plays the tick sound every 2 ticks, not every framePLAY BACKGROUNDplays without blocking the WHILE loop- Double
PAUSE— first to dismiss the dialog, second to prevent the dismiss keypress from being read as game input CLS -1clears only the current layer (the UI overlay)
Dialog Box with Choices
Extend the dialog box to return a player choice from an inline array of options:
FUNCTION DialogBox(textToSay, whoSaidIt, choices)
dialogActive = TRUE
SET LAYER LAYER_DIALOG
WINDOW (33,SCREENHEIGHT-64)-(SCREENWIDTH-32,SCREENHEIGHT-2)
LINE (32,SCREENHEIGHT-64)-(SCREENWIDTH-32,SCREENHEIGHT-2), 0, BF
LINE (32,SCREENHEIGHT-64)-(SCREENWIDTH-32,SCREENHEIGHT-2), 7, B
PRINT whoSaidIt;
IF LEN(whoSaidIt) > 0 THEN PRINT ": ";
PRINT WORDS RATE(40) textToSay
WHILE TYPING()
COOLDOWN 2
PLAY BACKGROUND "I30L32O2" + CHR$(ASC("A") + INT(RND*4))
END COOLDOWN
YIELD
WEND
IF LEN(choices) > 0 THEN
DialogBox = GetDecision(choices)
DELAY 200
ELSE
PAUSE
DELAY 200
END IF
CLS -1
dialogActive = FALSE
END FUNCTIONCall with an empty array [] for press-to-dismiss, or a string array for choices:
' Simple dismiss dialog
answer = Scripts.DialogBox("Welcome to the castle!", "Guard", [])
' Choice dialog — returns the chosen string
answer = Scripts.DialogBox("Save your progress?", "King", ["YES", "NO"])
IF answer = "YES" THEN CALL Stats.Save()The dialogActive flag is readable from other namespaces (Scripts.dialogActive), which lets movement patterns freeze while a dialog is open:
PATTERN "PlayerMovement"
"moveUp" WHEN KEYDOWN("UP") AND NOT Scripts.dialogActive
"moveDown" WHEN KEYDOWN("DOWN") AND NOT Scripts.dialogActive
END PATTERNDedicated UI Layer
Keep UI on a screen-sized layer that doesn't scroll with the game world:
' Layer 0: Map (world-sized, scrolls)
' Layer 1: Sprites (world-sized, scrolls)
' Layer 2: UI (screen-sized, never scrolls)
CREATE LAYER 1, MAPWIDTH(0)*32, MAPHEIGHT(0)*32
CREATE LAYER 2, 320, 240
' Only scroll layers 0 and 1
FOR i = 0 TO 1
SET LAYER i
SET SCROLL scrollX, scrollY
NEXT i
' Draw UI on layer 2 — stays fixed on screen
SET LAYER 2
PRINT "HP: "; playerHealthNPC Interaction
Action Button + Proximity
Require the player to press a button while near an NPC, using SPRITECOLLISION with a tag:
IF KEYDOWN("SPACE") THEN
IF SPRITECOLLISION(player, "king") THEN
CALL DialogBox("Would you like to save?", "King Kingsly")
END IF
END IFKey points:
SPRITECOLLISION(sprite, tagString)checks against all sprites with that tag- Put the keypress check FIRST (cheaper than collision check)
- The NPC must have
SET SPRITE npc TAG "king"andSOLIDfor collision to work
Wall Bump Feedback
Give the player feedback when they walk into a solid wall using SPRITETOUCHING$:
IF KEYDOWN("DOWN") THEN
IF SPRITETOUCHING$(player, "TOP") <> "" THEN
CALL DialogBox("Ouch!!", "Sir Blocksly")
END IF
END IFCheck the direction the player is pressing against the boundary edge they'd hit:
- Pressing DOWN → check boundary's TOP (sprite lands on it)
- Pressing UP → check boundary's BOTTOM (sprite bumps head)
- Pressing RIGHT → check boundary's LEFT (sprite hits wall)
- Pressing LEFT → check boundary's RIGHT (sprite hits wall)
Script Mode & Control Handoff
Disabling Player Input During Cutscenes
Use a global flag to block player movement patterns during scripted sequences:
scriptMode = FALSE
' Player patterns check the flag
PATTERN "PlayerMovement"
"moveUp" WHEN KEYDOWN("UP") AND NOT scriptMode
"moveDown" WHEN KEYDOWN("DOWN") AND NOT scriptMode
"moveLeft" WHEN KEYDOWN("LEFT") AND NOT scriptMode
"moveRight" WHEN KEYDOWN("RIGHT") AND NOT scriptMode
END PATTERN
' Scripted sequence runs, then hands control back
PATTERN "ScriptedMoveUp"
"moveUp" DURATION 32
BEVENT "EndScriptMode"
GOTOPATTERN "PlayerMovement"
END PATTERNThe BEVENT fires after the DURATION completes, triggering an event handler:
SUB EndScriptMode(spriteIndex)
scriptMode = FALSE
END SUB
CALL EVENT EndScriptMode() AS "EndScriptMode"To enter script mode:
IF shouldTriggerCutscene THEN
scriptMode = TRUE
SET SPRITE player PATTERN "ScriptedMoveUp"
CALL DialogBox("Something happened!", "Narrator")
END IFThe flow:
- Set
scriptMode = TRUE— player input patterns stop matching - Switch to a scripted pattern — moves the sprite automatically
- Show dialog — blocks the interpreter but behaviors keep ticking
- Pattern completes →
BEVENTfires →scriptMode = FALSE GOTOPATTERN "PlayerMovement"restores normal input
Multi-Layer Architecture
Three-Layer Setup for Tile Map Games
A common architecture for scrolling tile map games:
' Layer 0: Tile map (world-sized, created by CREATE MAP)
CREATE MAP ("level.map.json", 0) AS LAYER 0 SPEED 10
' Layer 1: Sprites (world-sized, matches map dimensions)
CREATE LAYER 1, MAPWIDTH(0)*32, MAPHEIGHT(0)*32
' Layer 2: UI overlay (screen-sized, never scrolls)
CREATE LAYER 2, SCREENWIDTH, SCREENHEIGHT| Layer | Size | Scrolls | Contents |
|---|---|---|---|
| 0 | Map size | Yes | Tile map, animated tiles |
| 1 | Map size | Yes | Player, NPCs, enemies, projectiles |
| 2 | Screen size | No | Health bar, dialog boxes, score |
Sprites go on Layer 1 (SET SPRITE id, x, y, "img" LAYER 1) so they scroll with the map. UI drawing goes on Layer 2 which stays fixed.
For multi-layer maps (foreground + background):
CREATE MAP ("level.map.json", 0 TO 2) AS LAYER 0 TO 2
CREATE LAYER 3, MAPWIDTH(0)*32, MAPHEIGHT(0)*32 ' Sprites
CREATE LAYER 4, SCREENWIDTH, SCREENHEIGHT ' UIPlane Layers: Tilting the Floor
Any layer — a drawn surface, a loaded image, or a tilemap — can be reprojected as a receding plane:
SET LAYER 0 PERSPECTIVE HORIZON(80) HEIGHT(40) ' Mode 7-style floor
SET LAYER 0 AFFINE ZOOM(1.5) ' rotated/scaled, no recede
SET LAYER 0 FLAT ' back to normalDrive the camera with SET SCROLL x, y ANGLE(deg), where x, y is the camera's world position and ANGLE its heading — so turning is a scroll parameter, not a redraw.
This is also what makes ELEVATION visible. A sprite's height above the floor is only rendered on an AFFINE/PERSPECTIVE layer, where it rides toward the camera eye-height and foreshortens with distance. That is the pairing to reach for when you want a racer, an overhead shooter with real jumps, or anything where "how high is it" should read visually — see Physics & Movement for BGRAVITY and BIMPULSE's third component.
Six-Layer RPG Setup
A full RPG might use dedicated layers for different UI concerns:
CONST LAYER_MAP1 = 0
CONST LAYER_MAP2 = 1
CONST LAYER_ILLUSTRATION = 2
CONST LAYER_BATTLE = 3
CONST LAYER_STATS = 4
CONST LAYER_DIALOG = 5
CREATE LAYER 0, 320, 240
CREATE LAYER 1, 320, 240
CREATE LAYER LAYER_DIALOG, 320, 240
CREATE LAYER LAYER_STATS, 320, 240
CREATE LAYER LAYER_BATTLE, 320, 240
SET LAYER LAYER_DIALOG ALPHA(0.8)| Layer | Purpose | Scrolls |
|---|---|---|
| 0-1 | Map tiles (background + foreground) | Yes |
| 2 | Illustration / cutscene overlays | No |
| 3 | Battle screen (drawn over map during combat) | No |
| 4 | Stats HUD (always visible) | No |
| 5 | Dialog box (semi-transparent overlay) | No |
Each game mode draws to its own layer and clears with CLS -1 when done. The alpha on the dialog layer lets the game show through behind text boxes.
Side Scroller Patterns
Layer Setup
Side scrollers typically need 3–5 layers:
SUB SetupLayers()
' Layer 0: Far background (sky, mountains) — slow parallax
' Layer 1: Mid background (trees, buildings) — medium parallax
' Layer 2: Main gameplay (tilemap, player, enemies)
' Layer 3: Foreground details (grass, fog) — fast parallax or 1:1
' Layer 4: HUD (no scrolling)
CREATE LAYER 0, SCREEN_W * 2, SCREEN_H
CREATE LAYER 1, SCREEN_W * 2, SCREEN_H
CREATE LAYER 2, SCREEN_W * 4, SCREEN_H ' Wide for scrollable level
CREATE LAYER 3, SCREEN_W * 2, SCREEN_H
CREATE LAYER 4, SCREEN_W, SCREEN_H
END SUBCamera / Scrolling
The camera follows the player with optional smoothing and clamping to level bounds.
' rendering.bas - Camera system
DIM cameraX AS SINGLE
DIM cameraY AS SINGLE
SUB UpdateCamera()
' Target: center player on screen
DIM targetX AS SINGLE
targetX = player.x - SCREEN_W / 2
' Smooth follow (lerp toward target)
cameraX = LERP(cameraX, targetX, 0.1)
' Clamp to level bounds
cameraX = CLAMP(cameraX, 0, levelWidth - SCREEN_W)
' Apply scroll to each layer with parallax ratios
SET LAYER 0
SET SCROLL (INT(cameraX * 0.1), 0) ' Far background: 10% speed
SET LAYER 1
SET SCROLL (INT(cameraX * 0.4), 0) ' Mid background: 40% speed
SET LAYER 2
SET SCROLL (INT(cameraX), 0) ' Gameplay: 1:1
SET LAYER 3
SET SCROLL (INT(cameraX * 1.2), 0) ' Foreground: slight overdrive
' Layer 4 (HUD) — no scrolling
END SUBTilemap Collision
For solid tiles in a side scroller, check the tile at the player's target position before moving.
' physics.bas - Tile-based collision
FUNCTION IsSolidTile(tileX AS INTEGER, tileY AS INTEGER) AS INTEGER
DIM tile AS INTEGER
tile = MAPTILE(tileX, tileY)
' Solid tiles are index 1-15 in this example
IsSolidTile = (tile >= 1 AND tile <= 15)
END FUNCTION
SUB ApplyPhysics(BYREF p AS Player)
' Gravity
p.vy = p.vy + GRAVITY
p.vy = CLAMP(p.vy, -12, 12) ' Terminal velocity
' Horizontal movement + tile collision
DIM newX AS SINGLE
newX = p.x + p.vx
DIM tileX AS INTEGER
DIM tileTop AS INTEGER
DIM tileMid AS INTEGER
DIM tileBot AS INTEGER
' Check tiles at leading edge
IF p.vx > 0 THEN
tileX = MAPWORLDTILEX(newX + 14) ' Right edge (assuming 16px wide)
ELSE
tileX = MAPWORLDTILEX(newX) ' Left edge
END IF
tileTop = MAPWORLDTILEY(p.y + 1)
tileMid = MAPWORLDTILEY(p.y + 8)
tileBot = MAPWORLDTILEY(p.y + 14)
IF IsSolidTile(tileX, tileTop) OR IsSolidTile(tileX, tileMid) OR IsSolidTile(tileX, tileBot) THEN
p.vx = 0 ' Blocked
ELSE
p.x = newX
END IF
' Vertical movement + tile collision
DIM newY AS SINGLE
newY = p.y + p.vy
DIM tileY AS INTEGER
DIM tileLeft AS INTEGER
DIM tileRight AS INTEGER
IF p.vy > 0 THEN
' Falling — check below
tileY = MAPWORLDTILEY(newY + 15) ' Bottom edge
tileLeft = MAPWORLDTILEX(p.x + 1)
tileRight = MAPWORLDTILEX(p.x + 14)
IF IsSolidTile(tileLeft, tileY) OR IsSolidTile(tileRight, tileY) THEN
' Snap to tile top
p.y = tileY * TILE_SIZE - 16
p.vy = 0
p.onGround = 1
ELSE
p.y = newY
p.onGround = 0
END IF
ELSEIF p.vy < 0 THEN
' Rising — check above
tileY = MAPWORLDTILEY(newY)
tileLeft = MAPWORLDTILEX(p.x + 1)
tileRight = MAPWORLDTILEX(p.x + 14)
IF IsSolidTile(tileLeft, tileY) OR IsSolidTile(tileRight, tileY) THEN
p.vy = 0 ' Bonk ceiling
ELSE
p.y = newY
END IF
ELSE
p.onGround = 0
END IF
END SUBPlayer Sprite with Behaviors
Use behaviors for animation and visual state. Your code handles physics and position, then tells the sprite which behavior to show.
' player_behaviors.bas
BEHAVIOR "player_idle"
ANIMATE "hero", 0, 1 FRAMERATE 4
END BEHAVIOR
BEHAVIOR "player_run"
ANIMATE "hero", 2, 3, 4, 5 EVENTSON(1, 3) FRAMERATE 12
END BEHAVIOR
BEHAVIOR "player_jump"
ANIMATE "hero", 6 FRAMERATE 1
END BEHAVIOR
BEHAVIOR "player_fall"
ANIMATE "hero", 7 FRAMERATE 1
END BEHAVIOR' player.bas
SUB InitPlayerSprite(BYREF p AS Player)
p.sprite = 0
ENABLE SPRITE p.sprite
SET SPRITE p.sprite, p.x, p.y, "hero", LAYER 2
SET SPRITE p.sprite TAG "player"
SET SPRITE p.sprite SOLID
END SUB
SUB UpdatePlayerSprite(BYREF p AS Player)
' Position is driven by physics, not by behaviors
MOVE SPRITE p.sprite, p.x, p.y
' Switch animation behavior based on physics state
IF p.vy < 0 THEN
SET SPRITE p.sprite BEHAVIOR "player_jump"
ELSEIF NOT p.onGround THEN
SET SPRITE p.sprite BEHAVIOR "player_fall"
ELSEIF ABS(p.vx) > 0 THEN
SET SPRITE p.sprite BEHAVIOR "player_run"
ELSE
SET SPRITE p.sprite BEHAVIOR "player_idle"
END IF
' Flip sprite to face movement direction
IF p.facing = -1 THEN
SET SPRITE p.sprite, p.x, p.y, "hero", FLIPX LAYER 2
END IF
END SUB
' Footstep sounds via behavior events
SUB OnFootstep(spriteId, frame)
PLAYWAV "sfx_step"
END SUB
CALL EVENT OnFootstep() AS "HERO_FRAME"Note the division of labor: physics code decides position and velocity. Behaviors handle animation and visual effects. Your game loop just bridges the two by switching which behavior is active.
Enemy Patterns
Enemies benefit even more from patterns, since their behavior is often fully declarative.
' enemy_behaviors.bas
BEHAVIOR "patrol_right"
ANIMATE "enemy_walk" FRAMERATE 8
BMOVE 1, 0
END BEHAVIOR
BEHAVIOR "patrol_left"
ANIMATE "enemy_walk" FRAMERATE 8
BMOVE -1, 0
BFLIPX ON
END BEHAVIOR
BEHAVIOR "enemy_hit"
BSHAKE AMPLITUDE 3
END BEHAVIOR
BEHAVIOR "enemy_die"
BALPHA 255 TO 0 DURATION 20
BSCALE 1, 1 TO 0.5, 0.5 DURATION 20
END BEHAVIOR
' Patrol pattern — walks back and forth automatically
PATTERN "enemy_patrol" LOOP
"patrol_right" DURATION 90
"patrol_left" DURATION 90
END PATTERN
' Death sequence — runs once, then removes the sprite
PATTERN "enemy_death"
GHOST
"enemy_hit" DURATION 10
"enemy_die"
DISABLE
END PATTERN' enemies.bas
SUB SpawnEnemy(x AS SINGLE, y AS SINGLE)
IF enemyCount >= MAX_ENEMIES THEN EXIT SUB
DIM s AS INTEGER
s = 100 + enemyCount
ENABLE SPRITE s
SET SPRITE s, x, y, "enemy_walk", LAYER 2
SET SPRITE s TAG "enemy"
SET SPRITE s SOLID
SET SPRITE s PATTERN "enemy_patrol"
enemies(enemyCount).sprite = s
enemies(enemyCount).alive = 1
enemyCount = enemyCount + 1
END SUB
SUB KillEnemy(index AS INTEGER)
' Switch to death pattern — it handles the animation and DISABLE
SET SPRITE enemies(index).sprite PATTERN "enemy_death"
enemies(index).alive = 0
END SUBWith patterns, killing an enemy is a single line. The death animation, ghost mode, fade, and sprite cleanup all happen automatically.
Level Transitions
' levels.bas
DIM levelWidth AS INTEGER
SUB LoadLevel(levelNum AS INTEGER)
' Load tilemap
CREATE MAP("maps/level" + STR$(levelNum) + ".map.json") AS LAYER 2
' Load level-specific background
LOADPNG "sprites/bg_level" + STR$(levelNum) + ".png", "level_bg"
' Read level width from map (in pixels)
' Typical: tile count * tile size
levelWidth = 200 * TILE_SIZE ' 200 tiles wide
' Reset camera
cameraX = 0
' Place player at start
CALL InitPlayer(player)
' Spawn enemies from map metadata
CALL SpawnEnemiesFromMap(levelNum)
END SUB
SUB SpawnEnemiesFromMap(levelNum AS INTEGER)
' Use FOR MAPDATA to read spawn points from the tilemap
FOR MAPDATA LAYER 2 (0, 0)-(200, 15) AS tx, ty, meta$, tileIdx
IF meta$ = "enemy_spawn" THEN
CALL SpawnEnemy(tx * TILE_SIZE, ty * TILE_SIZE, "walker")
ELSEIF meta$ = "flyer_spawn" THEN
CALL SpawnEnemy(tx * TILE_SIZE, ty * TILE_SIZE, "flyer")
END IF
NEXT MAPDATA
END SUB
SUB CheckLevelEnd()
' Trigger at right edge of level
IF player.x >= levelWidth - 32 THEN
currentLevel = currentLevel + 1
CALL LoadLevel(currentLevel)
END IF
END SUBTop-Down / Adventure Patterns
Top-down games share many patterns with side scrollers but use 2D scrolling and no gravity.
8-Directional Movement
SUB HandleTopDownInput(BYREF p AS Player)
p.vx = 0
p.vy = 0
IF KEYDOWN("LEFT") THEN p.vx = -2
IF KEYDOWN("RIGHT") THEN p.vx = 2
IF KEYDOWN("UP") THEN p.vy = -2
IF KEYDOWN("DOWN") THEN p.vy = 2
' Normalize diagonal movement so it's not faster
IF p.vx <> 0 AND p.vy <> 0 THEN
p.vx = p.vx * 0.707
p.vy = p.vy * 0.707
END IF
' Update facing direction for animation
IF p.vx > 0 THEN p.facing = 1
IF p.vx < 0 THEN p.facing = -1
END SUBCamera (2D Scroll)
SUB UpdateTopDownCamera()
DIM targetX AS SINGLE
DIM targetY AS SINGLE
targetX = player.x - SCREEN_W / 2
targetY = player.y - SCREEN_H / 2
cameraX = LERP(cameraX, targetX, 0.1)
cameraY = LERP(cameraY, targetY, 0.1)
cameraX = CLAMP(cameraX, 0, mapWidth - SCREEN_W)
cameraY = CLAMP(cameraY, 0, mapHeight - SCREEN_H)
SET LAYER 2
SET SCROLL (INT(cameraX), INT(cameraY))
END SUBTile Interaction (e.g., opening a chest)
SUB CheckTileInteraction()
IF KEYDOWN("Z") THEN
' Check the tile the player is facing
DIM checkX AS SINGLE
DIM checkY AS SINGLE
checkX = player.x + 8 + player.facing * TILE_SIZE
checkY = player.y + 8
DIM tx AS INTEGER
DIM ty AS INTEGER
tx = MAPWORLDTILEX(checkX)
ty = MAPWORLDTILEY(checkY)
DIM tile AS INTEGER
tile = MAPTILE(tx, ty)
IF tile = 42 THEN ' Chest tile
CALL OpenChest(tx, ty)
END IF
END IF
END SUBMenu & UI Patterns
Simple Menu with Cursor
DIM menuChoice AS INTEGER
SUB DrawMenu(items() AS STRING, numItems AS INTEGER, selected AS INTEGER)
DIM i AS INTEGER
FOR i = 0 TO numItems - 1
LOCATE i + 5, 10
IF i = selected THEN
GPRINT "> " + items(i)
ELSE
GPRINT " " + items(i)
END IF
NEXT i
END SUB
SUB MenuState()
DIM items(3) AS STRING
items(0) = "Start Game"
items(1) = "Options"
items(2) = "Quit"
menuChoice = 0
DO
BEGIN FRAME
CLS 0
GPRINT "MY GAME"
CALL DrawMenu(items(), 3, menuChoice)
END FRAME
COOLDOWN 10
IF KEYDOWN("UP") THEN menuChoice = menuChoice - 1
IF KEYDOWN("DOWN") THEN menuChoice = menuChoice + 1
menuChoice = CLAMP(menuChoice, 0, 2)
END COOLDOWN
IF KEYDOWN("SPACE") OR KEYDOWN("ENTER") THEN
IF menuChoice = 0 THEN SET STATE "PlayingState"
IF menuChoice = 1 THEN SET STATE "OptionsState"
IF menuChoice = 2 THEN END
END IF
YIELD
LOOP
END SUBAnchoring & Layout with IMAGEWIDTH / IMAGEHEIGHT
IMAGEWIDTH(name$) and IMAGEHEIGHT(name$) return the pixel dimensions of any loaded image — from LOADIMG, TEXTTOIMAGE, NEWIMAGE, or a BEGIN MEMORYBUFFER bake. Use them instead of hard-coding sizes so layouts survive asset swaps, font changes, and dynamic text.
' Pin a logo to the top-right corner with an 8px margin
LOADIMG "logo.png", "logo"
PUTIMAGE (SCREENWIDTH - IMAGEWIDTH("logo") - 8, 8), "logo"
' Center a splash image of any size on screen
SUB DrawCentered(name$)
DIM x AS INTEGER, y AS INTEGER
x = (SCREENWIDTH - IMAGEWIDTH(name$)) / 2
y = (SCREENHEIGHT - IMAGEHEIGHT(name$)) / 2
PUTIMAGE (x, y), name$
END SUB
' Stack rows vertically without hard-coding heights
DIM cursorY AS INTEGER
cursorY = 24
PUTIMAGE (16, cursorY), "row1"
cursorY = cursorY + IMAGEHEIGHT("row1") + 4
PUTIMAGE (16, cursorY), "row2"
cursorY = cursorY + IMAGEHEIGHT("row2") + 4
PUTIMAGE (16, cursorY), "row3"
' Tile an image across a layer to fill it
SUB TileBackground(name$)
DIM w AS INTEGER, h AS INTEGER, x AS INTEGER, y AS INTEGER
w = IMAGEWIDTH(name$)
h = IMAGEHEIGHT(name$)
FOR y = 0 TO SCREENHEIGHT STEP h
FOR x = 0 TO SCREENWIDTH STEP w
PUTIMAGE (x, y), name$
NEXT
NEXT
END SUBThese functions are essential when working with text rendered via TEXTTOIMAGE, where you don't know the rendered width up front:
TEXTTOIMAGE "Press START", "prompt", 24
' Right-align the prompt 12px from the right edge, 40px above the bottom
PUTIMAGE (SCREENWIDTH - IMAGEWIDTH("prompt") - 12, SCREENHEIGHT - 40), "prompt"HUD Layer
The HUD should be on its own non-scrolling layer so it stays fixed on screen.
SUB DrawHUD()
SET LAYER 4 ' Dedicated HUD layer — no scrolling
CLS -1 ' Clear with transparent
' Health bar
LINE (8, 8)-(8 + player.maxHealth * 12, 18), 4, BF ' Dark red background
LINE (8, 8)-(8 + player.health * 12, 18), 9, BF ' Bright red fill
' Score
LOCATE 1, 1 ORIGIN(SCREEN_W - 100, 8)
GPRINT "Score: " + STR$(score)
END SUBVector Math with Coordinates
Crash BASIC's Coordinate type — written as a parenthesis-tuple (x, y) or (x, y, z) — is a real N-dimensional vector. Arithmetic operators are overloaded for it, several useful AI built-ins consume and return coordinates, and the math reads the way you'd write it on paper. Use them for positions, velocities, accelerations, deltas, spawn points, waypoints, anything you'd otherwise track as a pair (or trio) of separate x / y / z variables.
Construction & Component Access
pos = (100, 80) ' 2D coordinate
target = (200, 150)
spawn = (0, 0, 10) ' 3D — Z is whatever you want it to mean
' Component access via .X .Y .Z .W
PRINT pos.X, pos.Y ' 100 80
PRINT spawn.Z ' 10
' Missing dimensions read as 0 (not an error)
PRINT pos.Z ' 0DIM v AS COORDINATE (or AS COORD) declares a typed coordinate variable. .X / .Y / .Z / .W are read-only — to "change" a component, build a new coord: pos = (5, pos.Y).
Vector Arithmetic with Operators
The arithmetic operators are overloaded:
| Op | Result | Notes |
|---|---|---|
coord + coord |
component-wise sum | mismatched dimensions pad with zero |
coord - coord |
component-wise difference | gives you a delta / direction vector |
coord * scalar |
scaled coordinate | scalar on either side |
-coord |
component-wise negation |
' Move a position by a velocity each frame
pos = pos + vel
' Delta from player to enemy (vector pointing player → enemy)
delta = enemyPos - playerPos
' Half-speed slow-mo
vel = vel * 0.5
' Bounce: reverse direction
vel = -vel
' Midpoint
mid = (a + b) * 0.5Division by a scalar isn't directly defined for coords — multiply by the reciprocal (coord * 0.5), or use SEEK when you want a normalized direction at a given speed.
Magnitude & Distance
' MAG returns the Euclidean length of a vector
speed = MAG(vel)
howFar = MAG(enemyPos - playerPos) ' same as DISTANCE(enemyPos, playerPos)
' DISTANCE is the shorthand
IF DISTANCE(playerPos, enemyPos) < 50 THEN
PRINT "Enemy in range!"
END IFMAG also accepts scalars (returns absolute value), so it's safe in code that might receive either a coord or a single number.
Direction & Normalization with SEEK / FLEE
To get a vector pointing from A to B at a chosen speed, use SEEK:
' Velocity vector toward target at 4 px/frame
vel = SEEK(pos, target, 4)
pos = pos + vel
' Move away from a threat at 6 px/frame
vel = FLEE(pos, threat, 6)
pos = pos + velSEEK and FLEE handle the zero-distance case for you (return (0, 0) when you're already on the target) — no divide-by-zero footgun. If you want a unit vector instead, manual normalization is one line:
delta = target - pos
m = MAG(delta)
IF m > 0 THEN dir = delta * (1.0 / m) ' unit vectorLinear Interpolation (LERP)
LERP(a, b, t) interpolates between two values. When both arguments are coordinates, it returns a coordinate — making it the natural fit for smooth camera tracking, dolly cuts, glides, and easing.
' Smooth camera follow — each frame, move 10% of the way toward target
cam = LERP(cam, playerPos, 0.1)
' Two-second cutscene glide
FOR f = 0 TO 120
t = f / 120
cameraPos = LERP(startPos, endPos, t)
YIELD
NEXT ft is 0 → returns a, 1 → returns b, 0.5 → midpoint.
Position / Velocity / Acceleration
The classic update loop reads naturally when all three are coordinates:
DIM pos AS COORDINATE
DIM vel AS COORDINATE
DIM acc AS COORDINATE
CONST GRAVITY = (0, 0.5)
DO
acc = GRAVITY
IF KEYDOWN("RIGHT") THEN acc = acc + (0.4, 0)
IF KEYDOWN("LEFT") THEN acc = acc - (0.4, 0)
IF KEYDOWN("SPACE") AND onGround THEN acc = acc + (0, -10)
vel = (vel + acc) * 0.98 ' apply force, then friction
pos = pos + vel
SET SPRITE player, pos.X, pos.Y
YIELD
LOOPNo vx / vy / ax / ay bookkeeping — the math reads the way physics is written.
Picking the Closest Target
NEAREST(pos, arrayOfCoords) returns the index of the nearest coordinate in an array (-1 if empty). Pair it with an array of spawn points, waypoints, or live enemy positions:
spawns = [(50, 50), (200, 80), (400, 120), (640, 60)]
idx = NEAREST(playerPos, spawns)
closest = spawns(idx)
PRINT "Closest spawn at "; closest.X; ","; closest.YFor "the nearest enemy this frame," build the array each tick from your live sprite positions and run NEAREST against it.
Random Wandering
WANDER(pos, angle, radius, distance) returns a new target position offset randomly from pos, biased in the angle direction (degrees). Good for idle NPCs or particles drifting around:
SUB IdleNPC(BYREF npc)
target = WANDER(npc.pos, npc.facing, 30, 60)
npc.pos = npc.pos + SEEK(npc.pos, target, 1)
END SUBWorking in 3D
A coordinate can carry as many components as you want. The arithmetic, MAG, DISTANCE, LERP, and SEEK all generalize:
playerPos3D = (100, 50, 20)
goal3D = (200, 50, 80)
vel3D = SEEK(playerPos3D, goal3D, 2) ' 3D velocity
distNow = DISTANCE(playerPos3D, goal3D)
midpoint = LERP(playerPos3D, goal3D, 0.5)The 2D sprite renderer ignores .Z, but you can use it as a depth-sort key, a layer-selection input, or carry it through your own logic untouched.
Common Pitfalls
.X / .Y / .Z / .Ware read-only.pos.X = 5is invalid. Build a new coord —pos = (5, pos.Y)— or add a delta.- Mismatched dimensions silently pad.
(1, 2) + (10, 20, 30)is(11, 22, 30). Convenient, but if you didn't intend the extra component you'll get a confusing result. Keep dimensions consistent within a code path. - Don't divide a coord by a scalar.
coord / 2doesn't scale — division falls back to coercing the coord to its magnitude. Usecoord * 0.5instead. - Sprite/draw calls take scalars, not coords.
SET SPRITE n, pos.X, pos.Y— pass components individually. Many graphics statements predate the Coordinate type. - Operator overloads vs. function calls.
MAG(v)andDISTANCE(a, b)are functions;+ - *are operators. There's nocoord.magnitude()method — coords aren't objects.
Physics & Movement
There are two ways to move things, and they are not competing — pick per game:
- You own the numbers. Keep velocity in your own
TYPE, integrate it yourself, test tiles yourself. This is what the tile-platformer patterns in this section do, and it stays the right answer when you want frame-exact control over a hand-tuned jump arc. - The behavior engine owns the numbers.
BIMPULSE,BACCELERATION,BFRICTIONandBBOUNCEaccumulate velocity on the sprite and resolve it againstSOLIDboundaries for you.
Engine-Side Physics: Impulse, Acceleration, Friction, Bounce
These four compose into most "physical" motion without a line of integration code:
BEHAVIOR "ball"
BIMPULSE 8, -4 ' one-shot kick of momentum, once per activation
BACCELERATION 0, 0.5 ' constant pull (here: downward)
BFRICTION 0.02, 0 ' bleed 2% of horizontal speed per tick
BBOUNCE 0.9, 0.9 ' keep 90% of the speed on each wall hit
END BEHAVIORTwo things that will save you an afternoon:
BBOUNCEreflects accumulated velocity — the momentum built byBIMPULSE,BACCELERATIONandBGRAVITY. It does not usefully bounceBVELOCITY, which is a constant re-applied every tick by design: the reflection happens and is immediately overwritten, so the sprite just presses steadily against the wall. UseBIMPULSEto start a bouncing thing moving.BFRICTIONpairs withBACCELERATIONto give a terminal velocity of roughlyacceleration / friction— a softer speed cap than clamping, and the natural way to model drag.
To reverse something on impact, drive it with BVELOCITY and flip the parameter from AFTERCOLLISION (see Collision Patterns). Re-issuing a running behavior with different @params rebuilds it; re-issuing with the same ones is a deliberate no-op, so a DO loop can re-assert a behavior every frame without restarting its timing.
Vertical Physics on Plane Layers: ELEVATION
Sprites have a third axis — ELEVATION, height above the layer they sit on. This is only rendered on a PERSPECTIVE or AFFINE plane layer; on a flat layer, elevation changes nothing visually, which is why the flat-platformer patterns below still do their own vertical work.
Where you do have a plane layer, a jump is three commands:
BEHAVIOR "hop"
BIMPULSE 0, 0, 16 ' kick upward (the third component is z)
BGRAVITY 1 ' constant pull back toward the floor
BBOUNCE 0, 0, 0.4 ' keep 40% on landing, for a bouncing thing
END BEHAVIORBGRAVITY strength is sugar for the vertical part of BACCELERATION 0, 0, -strength; use a positive strength. BFRICTION's optional third coefficient decays vertical speed for air resistance or a terminal fall speed. For a scripted lift rather than physics, BELEVATE from TO to DURATION ticks tweens elevation the way BALPHA tweens opacity. Read the live value with SPRITEELEVATION(id) — useful in a WHEN condition for landing detection.
Reusable Gravity + Friction
' physics.bas
SUB ApplyGravity(BYREF vy AS SINGLE)
vy = vy + GRAVITY
vy = CLAMP(vy, -12, 12)
END SUB
SUB ApplyFriction(BYREF vx AS SINGLE, friction AS SINGLE)
IF ABS(vx) < friction THEN
vx = 0
ELSEIF vx > 0 THEN
vx = vx - friction
ELSE
vx = vx + friction
END IF
END SUBWall Jump
SUB HandleWallJump(BYREF p AS Player)
IF NOT p.onGround AND KEYDOWN("SPACE") THEN
' Check if touching a wall
DIM leftTile AS INTEGER
DIM rightTile AS INTEGER
DIM midY AS INTEGER
midY = MAPWORLDTILEY(p.y + 8)
leftTile = MAPWORLDTILEX(p.x - 1)
rightTile = MAPWORLDTILEX(p.x + 16)
IF IsSolidTile(leftTile, midY) THEN
' Wall on left — jump up and to the right
p.vx = 4
p.vy = -7
p.facing = 1
PLAYWAV "sfx_walljump"
ELSEIF IsSolidTile(rightTile, midY) THEN
' Wall on right — jump up and to the left
p.vx = -4
p.vy = -7
p.facing = -1
PLAYWAV "sfx_walljump"
END IF
END IF
END SUBCoyote Time (Forgiving Jump Window)
DIM coyoteFrames AS INTEGER
SUB UpdateCoyoteTime(BYREF p AS Player)
IF p.onGround THEN
coyoteFrames = 6 ' 6 frames of grace after leaving a platform
ELSEIF coyoteFrames > 0 THEN
coyoteFrames = coyoteFrames - 1
END IF
END SUB
SUB HandleJump(BYREF p AS Player)
IF KEYDOWN("SPACE") AND (p.onGround OR coyoteFrames > 0) THEN
p.vy = -8
p.onGround = 0
coyoteFrames = 0
PLAYWAV "sfx_jump"
END IF
END SUBManaging Entities
Entity Pool Pattern
Pre-allocate a fixed array and track count. Avoid REDIM during gameplay.
' enemies.bas
DIM enemies(MAX_ENEMIES) AS Enemy
DIM enemyCount AS INTEGER
SUB SpawnEnemy(x AS SINGLE, y AS SINGLE, kind AS STRING)
IF enemyCount >= MAX_ENEMIES THEN EXIT SUB
enemies(enemyCount).x = x
enemies(enemyCount).y = y
enemies(enemyCount).vx = -1
enemies(enemyCount).vy = 0
enemies(enemyCount).kind = kind
enemies(enemyCount).health = 1
enemies(enemyCount).alive = 1
' Assign a sprite
enemies(enemyCount).sprite = 100 + enemyCount
ENABLE SPRITE enemies(enemyCount).sprite
SET SPRITE enemies(enemyCount).sprite, x, y, "enemy_" + kind, LAYER 2
SET SPRITE enemies(enemyCount).sprite TAG "enemy"
enemyCount = enemyCount + 1
END SUB
SUB RemoveEnemy(index AS INTEGER)
' Disable the sprite
DISABLE SPRITE enemies(index).sprite
enemies(index).alive = 0
' Swap with last active enemy to keep the array packed
IF index < enemyCount - 1 THEN
enemies(index) = enemies(enemyCount - 1)
' Update the swapped enemy's sprite position
MOVE SPRITE enemies(index).sprite, enemies(index).x, enemies(index).y
END IF
enemyCount = enemyCount - 1
END SUBIterating Enemies
SUB UpdateEnemies()
DIM i AS INTEGER
i = 0
DO WHILE i < enemyCount
IF enemies(i).alive THEN
CALL UpdateSingleEnemy(i)
CALL CheckEnemyCollisions(i)
i = i + 1
ELSE
CALL RemoveEnemy(i)
' Don't increment i — the swapped enemy now occupies this slot
END IF
LOOP
END SUBCollision with Sprites
Use FOR COLLISIONS for broad-phase sprite collision detection:
SUB CheckAllCollisions()
FOR COLLISIONS a, b
DIM tagA AS STRING
DIM tagB AS STRING
tagA = SPRITETAG$(a)
tagB = SPRITETAG$(b)
IF tagA = "player" AND tagB = "enemy" THEN
CALL PlayerHitEnemy(a, b)
ELSEIF tagA = "enemy" AND tagB = "player" THEN
CALL PlayerHitEnemy(b, a)
ELSEIF tagA = "player" AND tagB = "pickup" THEN
CALL CollectPickup(b)
END IF
NEXT COLLISION
END SUBSave & Load
Quick Save with SAVEJSON / LOADJSON
The simplest approach — save an entire typed object or namespace variable in one call:
' Save the entire Stats object to disk
SAVEJSON "saveData.json", Stats.Stats
' Load it back — restores all fields
Stats.Stats = LOADJSON("saveData.json")Sorting and Querying Game Data
BEGIN SORT sorts a 1-D array in place with your own comparator. The two elements under comparison are bound to the names you give, and the block returns a three-way sign:
BEGIN SORT scores AS a, b
RETURN b.points - a.points ' descending by points
END SORTThe sort is stable, so equal elements keep their original order — which is what you want for a leaderboard where ties should stay in insertion order. A RETURN inside the block yields that one comparison; it does not return from the enclosing SUB.
For reaching into loaded JSON, JMES runs a JMESPath query in one line instead of a nest of loops:
data = LOADJSON("levels.json")
bossNames = JMES(data, "levels[?boss].name")
hardest = JMES(data, "max_by(levels, &difficulty).name")A no-match returns 0, so guard the result before using it.
Manual Save with SERIALIZE$ / SAVETEXT
Use SERIALIZE$ / DESERIALIZE$ with SAVETEXT / LOADTEXT for more control. The format is JSON, so you can inspect save files in any text editor.
' save.bas
TYPE SaveData
level AS INTEGER
score AS INTEGER
health AS INTEGER
x AS SINGLE
y AS SINGLE
END TYPE
SUB SaveGame()
DIM save AS SaveData
save.level = currentLevel
save.score = score
save.health = player.health
save.x = player.x
save.y = player.y
SAVETEXT "savegame.json", SERIALIZE$(save)
END SUB
SUB LoadGame()
IF EXISTS("savegame.json") THEN
DIM raw AS STRING
OPEN "savegame.json" FOR INPUT AS #1
LINE INPUT #1, raw
CLOSE #1
DIM save AS SaveData
save = DESERIALIZE$(raw)
currentLevel = save.level
score = save.score
player.health = save.health
player.x = save.x
player.y = save.y
CALL LoadLevel(currentLevel)
END IF
END SUBAsync & Parallel Work
ASYNC spawns a worker thread to run a function and returns a Promise immediately. RESOLVE blocks until that promise (or every promise you pass it) completes, then returns the result. Reach for it whenever you have work that doesn't have to be sequential — network calls, asset downloads, expensive computations, anything where wall-clock time matters more than execution order.
p = ASYNC SlowThing(42)
DoOtherWorkInParallel()
result = RESOLVE(p)ASYNC works on any FUNCTION, SUB, or native built-in. Arguments are evaluated eagerly in the caller's scope — the worker sees the argument values as they were at spawn time, not whatever the caller's variables happen to be later. That kills the classic loop-variable-closure footgun before it can land.
Parallel Network Fetches
The biggest win is parallelizing I/O. Three serial fetches take the sum of their durations; three in parallel take the max:
p1 = ASYNC FETCH("https://cdn.example.com/leaderboard", "GET")
p2 = ASYNC FETCH("https://cdn.example.com/news", "GET")
p3 = ASYNC FETCH("https://cdn.example.com/sprite.png", "GET")
results = RESOLVE(p1, p2, p3)
leaderboard = results(0)
news = results(1)
sprite = results(2)RESOLVE with multiple promises returns an array in declaration order, regardless of which one finishes first. Total wall-clock = MAX(durations), not the sum.
Overlap Work With I/O
When something else useful can run during the wait, don't block — kick off the async, do the work, and only resolve when you actually need the result:
' Kick off the slow load…
scorePromise = ASYNC FETCH("https://cdn.example.com/scores", "GET")
' …prep the UI while it's in flight
DrawSplashScreen()
LoadLocalAssets()
' …block only when you need the value
scores = RESOLVE(scorePromise)
RenderLeaderboard(scores.MESSAGE)Bulk Asset Download from a CDN
The pattern that justifies ASYNC for most game-side use — fan-out a batch of HTTP loads at startup, then decode them when they're all back:
SUB LoadSpritesFromCDN()
p1 = ASYNC FETCH(CDN + "player.png", "GET")
p2 = ASYNC FETCH(CDN + "enemy.png", "GET")
p3 = ASYNC FETCH(CDN + "platform.png", "GET")
p4 = ASYNC FETCH(CDN + "items.png", "GET")
r = RESOLVE(p1, p2, p3, p4)
BASE64TOIMG r(0).MESSAGE, "player"
BASE64TOIMG r(1).MESSAGE, "enemy"
BASE64TOIMG r(2).MESSAGE, "platform"
BASE64TOIMG r(3).MESSAGE, "items"
END SUBFour 200 ms fetches in series = 800 ms boot delay. In parallel = ~200 ms.
Spawning N Workers in a Loop
When the count is dynamic, store promises in an array, spawn in one loop, resolve in another. Eager-arg evaluation captures i correctly per iteration:
DIM promises(N - 1)
DIM chunks(N - 1)
FOR i = 0 TO N - 1
promises(i) = ASYNC LoadChunk(i)
NEXT i
' All N workers are already running in parallel; this loop
' just waits on each in turn. Total time is still max(durations).
FOR i = 0 TO N - 1
chunks(i) = RESOLVE(promises(i))
NEXT iRe-Resolve Returns the Cached Value
Calling RESOLVE on the same promise twice doesn't re-run the work — the second call returns the cached result immediately. Pair it with a "have we started loading?" flag to build a lazy "ensure ready" gate:
DIM leaderboardLoaded AS INTEGER
DIM leaderboardPromise
DIM leaderboardData AS Object
SUB EnsureLeaderboard()
IF NOT leaderboardLoaded THEN
leaderboardPromise = ASYNC FETCH(LB_URL, "GET")
leaderboardLoaded = 1
END IF
leaderboardData = RESOLVE(leaderboardPromise) ' first call waits, later calls are instant
END SUBAsync vs. CALL THREAD / CALL TIMER
| Mechanism | Returns a value? | Best for |
|---|---|---|
ASYNC + RESOLVE |
Yes — promise → result | One-shot work whose result you need |
CALL THREAD |
No | Long-running background work with no single result |
CALL TIMER |
No | Periodic ticks ("every 500 ms ...") |
CALL RENDER |
No | Per-frame hooks synced to the 60 Hz behavior tick |
Reach for ASYNC when you'll need the result and want to overlap the wait with other work. Reach for CALL THREAD when you want fire-and-forget background activity.
Common Pitfalls
- Errors inside an async body halt the whole program — they are NOT isolated to the promise. ASYNC is for parallelism, not error containment. Validate inputs before the spawn, or check error conditions in the body and return a sentinel the caller inspects after
RESOLVE. - Don't spawn an async every frame. Worker-thread creation isn't free; a hot loop that fires
ASYNCevery tick will accumulate workers faster than they finish. Spawn once and cache, or useCALL TIMERfor periodic work. - Forgetting to RESOLVE doesn't crash, but it wastes CPU. The worker still runs to completion and discards its result. No leak, just throwaway work.
- Spawn-site errors are immediate. Wrong arity or an undefined function in
ASYNC LoadLeve(1)halts at the spawn site, not later atRESOLVE— which is exactly where you want the diagnostic. - Network errors don't halt — HTTP status does.
FETCHinside an async returns an Object withSTATUSCODE = 0on network failure. Check it afterRESOLVErather than assuming success.
Audio Patterns
Centralized Audio Loading
Load all sounds once at startup. Never reload during gameplay.
' audio.bas
SUB LoadAllAudio()
' Sound effects
LOADWAV "sounds/jump.wav", "sfx_jump"
LOADWAV "sounds/land.wav", "sfx_land"
LOADWAV "sounds/hit.wav", "sfx_hit"
LOADWAV "sounds/pickup.wav", "sfx_pickup"
LOADWAV "sounds/explosion.wav", "sfx_boom"
' Music
LOADMP3 "sounds/title_theme.mp3", "mus_title"
LOADMP3 "sounds/level_theme.mp3", "mus_level"
LOADMP3 "sounds/boss_theme.mp3", "mus_boss"
END SUBMusic State Management
Only one music track plays at a time. Manage transitions cleanly.
DIM currentMusic AS STRING
SUB PlayTrack(trackName AS STRING)
IF currentMusic = trackName THEN EXIT SUB
IF LEN(currentMusic) > 0 THEN
STOPMP3 currentMusic
END IF
PLAYMP3 trackName, 1 ' Loop
currentMusic = trackName
END SUBProcedural Textures with MEMORYBUFFER
BEGIN MEMORYBUFFER ... END MEMORYBUFFER batches a bunch of primitive draws (PSET, LINE, CIRCLE, POLYGON, CLS) and bakes them into a single RGBA texture in one upload. Use it for procedural backgrounds, generated sprites, icons, masks, recolors — anything you build from primitives once and then reuse.
The win is twofold: fewer round-trips (200 individual PSET commands collapse to 1 batched rasterize), and the result is a reusable texture. Drawing 200 stars every frame is 200 commands per frame forever; baking those stars once into a "stars" texture and PUTIMAGE-ing it is 1 upload at startup + 1 draw call per frame. On Windows in particular, batching like this cut native startup time noticeably.
The resulting texture works like any loaded image — pass it to SET SPRITE, PUTIMAGE, particles, or feed it back into another MEMORYBUFFER ... FROM block.
Basic Pattern
' Bake a 320x240 starfield once at startup, reuse it every frame
SCREEN 1, 320, 240
BEGIN MEMORYBUFFER "stars", 320, 240
CLS 0
FOR i = 1 TO 200
PSET (RND * 320, RND * 240), 15
NEXT
END MEMORYBUFFER
DO
CLS
PUTIMAGE (0, 0), "stars"
' ... draw the rest of the frame ...
CALL RENDER
LOOPOmit the dimensions to match the active SCREEN size. Maximum is 8192×8192.
Generated Sprites — No PNG Required
Build a sprite icon entirely from primitives:
BEGIN MEMORYBUFFER "coin", 16, 16
CLS -1 ' transparent background
CIRCLE (8, 8), 7, 14, 1 ' filled yellow disk
CIRCLE (8, 8), 7, 6 ' darker outline
LINE (6, 5)-(6, 11), 6 ' notch detail
END MEMORYBUFFER
ENABLE SPRITE 0
SET SPRITE 0, 100, 100, "coin"Recoloring with FROM
BEGIN MEMORYBUFFER "out" FROM "source" seeds the buffer from an existing texture and sizes it to that source. Your in-block draws composite on top — recolor, tint, outline, mask. The common use is post-processing TEXTTOIMAGE output, where you can't pre-author every color of every label.
IF ME.ISCLIENT THEN
TEXTTOIMAGE "PRESS START", "prompt", 32
' Repaint every opaque glyph pixel red
BEGIN MEMORYBUFFER "prompt-red" FROM "prompt"
FOR y = 0 TO IMAGEHEIGHT("prompt") - 1
FOR x = 0 TO IMAGEWIDTH("prompt") - 1
IF POINT(x, y) <> -1 THEN PSET (x, y), 4
NEXT
NEXT
END MEMORYBUFFER
END IFPOINT(x, y) returns the palette index of the source pixel — or -1 if it's transparent — sampled from the frozen source as it was at BEGIN. Your in-block writes never feed back into POINT, so neighbour-reading filters (blur, edge-detect, outline) won't smear into their own output.
For non-palette sources (photos, gradients), use POINTRGB(x, y) which returns the raw 0xAARRGGBB packed colour. Compose with BITAND / SHR to pull channels.
Outline Filter
A four-neighbour edge detect that wraps any rendered text in a black outline:
IF ME.ISCLIENT THEN
TEXTTOIMAGE "BOSS FIGHT", "title", 48
BEGIN MEMORYBUFFER "title-outlined" FROM "title"
FOR y = 1 TO IMAGEHEIGHT("title") - 2
FOR x = 1 TO IMAGEWIDTH("title") - 2
IF POINT(x, y) = -1 THEN
IF POINT(x-1, y) <> -1 OR POINT(x+1, y) <> -1 OR _
POINT(x, y-1) <> -1 OR POINT(x, y+1) <> -1 THEN
PSET (x, y), 0
END IF
END IF
NEXT
NEXT
END MEMORYBUFFER
END IFWhat's Allowed Inside
| Allowed | Not Allowed |
|---|---|
PSET, PRESET, LINE, CIRCLE, POLYGON (solid/fill) |
Sprites, PRINT, PUTIMAGE, SETIMAGE |
CLS [color] (fills the whole buffer) |
Resource loads (LOADIMG, TEXTTOIMAGE, LOADWAV, ...) |
IF, FOR, WHILE, DO, variable assignments |
SCREEN, BEGIN FRAME, audio statements |
SUB / FUNCTION calls (primitive-only helpers) |
Nested BEGIN MEMORYBUFFER |
POINT / POINTRGB (only inside a FROM block) |
Textured POLYGON (GPU-only operation) |
A transparent PRESET inside a FROM block erases the seeded pixel — useful for cut-outs and masking.
Multiplayer
MEMORYBUFFER is a computed resource, same model as TEXTTOIMAGE: it runs on every interpreter, self-registers the texture name and dimensions everywhere, but pixels are not broadcast. Each client rasterizes its own identical copy (rasterization is deterministic), so per-pixel bandwidth is zero.
FROM reads pixel data from a source texture and is therefore client-only. Wrap any BEGIN MEMORYBUFFER ... FROM block in IF ME.ISCLIENT THEN — calling it on a server-side interpreter is a fatal error.
IF ME.ISCLIENT THEN
BEGIN MEMORYBUFFER "tinted" FROM "logo"
' ... pixel ops ...
END MEMORYBUFFER
END IFPerformance Tips
1. Minimize Layer Redraws
If a layer doesn't change (e.g., a static background), draw it once and skip it in the frame loop.
DIM backgroundDrawn AS INTEGER
SUB DrawWorld()
IF NOT backgroundDrawn THEN
SET LAYER 0
PUTIMAGE (0, 0), "sky"
backgroundDrawn = 1
END IF
' Only redraw dynamic layers every frame
SET LAYER 2
CLS -1
' ... draw gameplay ...
END SUB2. Use Sprites Instead of PUTIMAGE for Moving Objects
The sprite system handles rendering internally and is optimized for many moving objects. Use PUTIMAGE for static backgrounds and UI; use sprites for anything that moves.
3. COOLDOWN for Input Debouncing
Prevents rapid-fire key repeats without manual timer tracking.
COOLDOWN 15
IF KEYDOWN("UP") THEN menuChoice = menuChoice - 1
IF KEYDOWN("DOWN") THEN menuChoice = menuChoice + 1
END COOLDOWNCOOLDOWN N means once per N/60 second. The optional second argument changes the resolution for sub-tick pacing — COOLDOWN 1, 240 runs at most once per 1/240 s. The first execution always runs immediately, each block is tracked independently by its position in the source, and timing is measured against a monotonic wall clock, so a block paces the same regardless of frame rate.
4. SET YIELD — Only If You Pace Yourself
YIELD pauses 16ms (~60fps) by default, which stops an unpaced loop from flooding the graphics command stream on devices whose renderer can't drain it that fast.
SET YIELD 1 ' free the loop to run as fast as the machine allowsDo this only when you pace your own rendering — with COOLDOWN, or a CALL RENDER loop. Otherwise motion runs as fast as the CPU allows, which is to say differently on every machine. The setting persists until changed and applies to every subsequent YIELD.
5. Avoid String Allocations in Hot Loops
Don't build strings with + concatenation inside a per-frame loop if you can avoid it. Pre-compute display strings when values change.
6. Use FOR SPRITES for Tag-Based Iteration
More efficient than manually checking every sprite index:
FOR SPRITES s, "enemy"
IF SPRITEX(s) < cameraX - 64 THEN
DISABLE SPRITE s
END IF
NEXT SPRITE7. Keep Layer Sizes Reasonable
Layers should be as small as they need to be. A HUD layer should be screen-sized, not level-sized.
CREATE LAYER 4, SCREEN_W, SCREEN_H ' HUD: screen-sized, no scrolling
CREATE LAYER 2, levelWidth, SCREEN_H ' Gameplay: level-width for scrolling8. Bake Static Primitives with MEMORYBUFFER
Drawing the same hundreds of primitives every frame (stars, grid lines, a procedural icon, a generated background) is wasted work. Bake them once into a BEGIN MEMORYBUFFER texture and PUTIMAGE that single texture each frame instead. See Procedural Textures with MEMORYBUFFER.