Game Patterns & Code Organization

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

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"

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 files

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

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

Import Rules

  • Imports are recursive — if main.bas imports player.bas, and player.bas imports data.bas, then main.bas gets data.bas too (inlined through player.bas).
  • Don't import the same file from multiple modules. The preprocessor inlines file contents, so importing data.bas from both main.bas and player.bas would duplicate its definitions. Instead, structure your imports as a tree: main.bas imports 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 NAMESPACE

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

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

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

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

Direction 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, facing

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

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

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

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

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

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

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

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

JOYCONNECTED() 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 now

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

What 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 SCREEN dimensions — so you can SCREEN 800, 600 inside 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 state

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

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

Rooms & 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 town

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

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

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

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

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

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

CLONE breaks the link, recursively duplicating nested arrays and objects:

checkpoint = CLONE(playerState)   ' independent snapshot
playerState.hp = 0                ' checkpoint.hp is untouched

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

Key points:

  • DATA blocks are named and can live anywhere in the code
  • OPEN DATA ... AS #n reads them like a file handle
  • Object literals {key: value, ...} create objects inline
  • CHOOSE(array) picks a random element
  • PUSH appends 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
WEND

The Game Loop

CrashBASIC gives you two patterns for game loops: polling with a DO/LOOP, or callback-driven with CALL RENDER.

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 SUB

YIELD is essential — it releases the thread so the engine can process input events and maintain frame timing. Without it, your game will freeze.

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 SUB

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

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

Behaviors & 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 BEHAVIOR

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

Parameterized 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=200

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

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

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

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

AUTOBBOX 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) RIGID

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

IK, 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 NONE

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

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

The 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 why dx above is declared before BEHAVIOR.
  • No blocking statements. PAUSE, DELAY, DELAY UNTIL and YIELD are 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, GHOST or SOLID from 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.
  • INCLUDES stacks handlers: every inherited ONCOLLISION runs, 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 COLLISION

Right — 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 IF

This pattern works because:

  • A 32px sprite can overlap two adjacent 32px boundary tiles simultaneously
  • FOR COLLISIONS reports 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 IF

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

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

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

The 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 RENDER runs the SUB every frame, independent of the game loop
  • SEEK() returns a normalized direction vector — multiply by speed to control how fast the camera follows
  • DISTANCE() 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 MAPDATA

Key points:

  • ENABLE SPRITE before SET SPRITE reserves the slot so NEXTFREESPRITE() won't return the same ID
  • Use TAG for sprite-type identification (used by SPRITECOLLISION and FOR SPRITES)
  • Separate wall boundaries (SOLID) from trigger zones (no SOLID keyword)
  • 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 IF

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

Dialog & 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 SUB

Key points:

  • PRINT WORDS wraps text at word boundaries within the WINDOW
  • RATE(40) controls typewriter speed (characters per second)
  • TYPING() returns true while the typewriter effect is active
  • COOLDOWN 2 plays the tick sound every 2 ticks, not every frame
  • PLAY BACKGROUND plays 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 -1 clears 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 FUNCTION

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

Dedicated 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: "; playerHealth

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

Key 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" and SOLID for 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 IF

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

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

The flow:

  1. Set scriptMode = TRUE — player input patterns stop matching
  2. Switch to a scripted pattern — moves the sprite automatically
  3. Show dialog — blocks the interpreter but behaviors keep ticking
  4. Pattern completes → BEVENT fires → scriptMode = FALSE
  5. 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          ' UI

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

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

Camera / 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 SUB

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

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

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

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

Camera (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 SUB

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

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 SUB

Anchoring & 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 SUB

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

Vector 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                    ' 0

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

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

MAG 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 + vel

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

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

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

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

For "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 SUB

Working 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 / .W are read-only. pos.X = 5 is 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 / 2 doesn't scale — division falls back to coercing the coord to its magnitude. Use coord * 0.5 instead.
  • 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) and DISTANCE(a, b) are functions; + - * are operators. There's no coord.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, BFRICTION and BBOUNCE accumulate velocity on the sprite and resolve it against SOLID boundaries 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 BEHAVIOR

Two things that will save you an afternoon:

  • BBOUNCE reflects accumulated velocity — the momentum built by BIMPULSE, BACCELERATION and BGRAVITY. It does not usefully bounce BVELOCITY, 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. Use BIMPULSE to start a bouncing thing moving.
  • BFRICTION pairs with BACCELERATION to give a terminal velocity of roughly acceleration / 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 BEHAVIOR

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

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

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

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

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

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

Save & 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 SORT

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

Async & 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 SUB

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

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

Async 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 ASYNC every tick will accumulate workers faster than they finish. Spawn once and cache, or use CALL TIMER for 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 at RESOLVE — which is exactly where you want the diagnostic.
  • Network errors don't halt — HTTP status does. FETCH inside an async returns an Object with STATUSCODE = 0 on network failure. Check it after RESOLVE rather 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 SUB

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

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

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

POINT(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 IF

What'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 IF

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

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

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

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

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

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