RGC BASIC — authoring levels (MAPLOAD vs BASIC builder)
RGC BASIC ships two ways to load a tile-based level into the
maplib MAP_* globals:
- JSON map files loaded at runtime by
MAPLOAD path$(canonical, v1.1+). - BASIC builder functions that fill
MAP_BG(),MAP_OBJ_*()etc. by hand (the v0 approach the Zelda-lite MVP shipped with).
Both end up in the same place — the MAP_* globals — so the
runtime engine, collision, camera, and rendering code is identical.
The difference is where the level data lives: an external,
diffable JSON file, or a FUNCTION of FOR/MAP_BG(…)=…
assignments inline with your game code.
This page shows both, and explains when each wins.
Quick comparison
| BASIC builder | JSON + MAPLOAD |
|
|---|---|---|
| Edit in any text tool | yes | yes |
| Diff cleanly in git | yes (mostly) | yes |
| External tools (Tiled) | no | one-way import planned |
| Procedural / RNG levels | natural | needs an export pass |
| Hot-reload at runtime | re-run program | MAPLOAD again |
| Map size limit | 4 KB BASIC string × N | bounded by free heap |
| Where rendering code is | unchanged | unchanged |
For static, hand-authored levels the JSON path is the canonical choice from v1.1 onward. For generated levels (procedural caves, seeded overworld) the BASIC builder still wins — you can run a generator on every level entry without touching disk.
Side by side: same overworld, two ways
Both snippets produce the same 32×32 grass-with-tree-border overworld with a water pond, a dirt L-shaped path, sand around a door, three rocks, and three objects (player spawn, door to cave, NPC).
BASIC builder (level1_overworld.bas)
GRASS_TILE = 1
TREE_TILE = 563
DIRT_TILE = 1402
SAND_TILE = 733
WATER_TILE = 17
ROCK_TILE = 208
DOOR_TILE = 346
FUNCTION LoadOverworld()
MAP_W = 32
MAP_H = 32
MAP_TILE_W = 16
MAP_TILE_H = 16
' Floor every cell with grass.
FOR I = 0 TO MAP_W * MAP_H - 1
MAP_BG(I) = GRASS_TILE
MAP_FG(I) = 0
NEXT I
' Tree border + water pond + dirt path + sand patch + rocks
' (~50 more lines of FOR / MAP_BG(R*MAP_W+C) = TILE assignments) ...
' Solid tile id list.
MAP_COLL_COUNT = 3
MAP_COLL(0) = WATER_TILE
MAP_COLL(1) = TREE_TILE
MAP_COLL(2) = ROCK_TILE
' Object list.
MAP_OBJ_COUNT = 3
MAP_OBJ_TYPE$(0) = "spawn"
MAP_OBJ_KIND$(0) = "player"
MAP_OBJ_X(0) = 4 * 16
MAP_OBJ_Y(0) = 28 * 16
'... etc ...
END FUNCTION
Caller does #INCLUDE "level1_overworld.bas" then
LoadOverworld() to populate the globals.
JSON + MAPLOAD (level1_overworld.json)
{
"format": 1,
"id": "level1-overworld",
"size": { "cols": 32, "rows": 32 },
"tileSize": { "w": 16, "h": 16 },
"tilesets": [
{
"id": "overworld",
"src": "Overworld.png",
"cellW": 16, "cellH": 16,
"tiles": {
"17": { "solid": true, "kind": "water" },
"563": { "solid": true, "kind": "tree" },
"208": { "solid": true, "kind": "rock" }
}
}
],
"layers": [
{ "name": "bg", "type": "tiles", "tilesetId": "overworld",
"data": [ /* 32 * 32 = 1024 ints, row-major */ ] },
{ "name": "fg", "type": "tiles", "tilesetId": "overworld",
"data": [ /* same shape */ ] },
{ "name": "obj", "type": "objects", "objects": [
{ "id": 1, "type": "spawn", "kind": "player",
"shape": "point", "x": 64, "y": 448 },
{ "id": 2, "type": "door", "kind": "cave",
"shape": "rect", "x": 256, "y": 128, "w": 16, "h": 16,
"props": { "leadsTo": "level1-cave", "spawnAt": 1 } },
{ "id": 3, "type": "npc", "kind": "old_man",
"shape": "rect", "x": 128, "y": 384, "w": 16, "h": 32,
"props": { "dialogue": "OLD MAN: TAKE THIS GUIDE TO THE CAVE." } }
]}
],
"camera": { "start": { "x": 0, "y": 0 },
"scrollDir": "free", "speedPxPerSec": 0,
"mode": "follow" }
}
Caller does:
DIM MAP_BG(1023)
DIM MAP_FG(1023)
DIM MAP_COLL(15)
DIM MAP_OBJ_TYPE$(15) : DIM MAP_OBJ_KIND$(15)
DIM MAP_OBJ_X(15) : DIM MAP_OBJ_Y(15)
DIM MAP_OBJ_W(15) : DIM MAP_OBJ_H(15)
DIM MAP_OBJ_ID(15)
DIM MAP_TILESET_ID$(7) : DIM MAP_TILESET_SRC$(7)
MAPLOAD "level1_overworld.json"
The arrays are pre-DIMmed by the caller, not by the loader, so
the engine knows its own caps. MAPLOAD writes count globals
(MAP_OBJ_COUNT, MAP_COLL_COUNT, MAP_TILESET_COUNT) so loops
don't need a hard-coded length.
What MAPLOAD populates
After a successful MAPLOAD call, these globals reflect the loaded
map:
| Global | Source field |
|---|---|
MAP_W, MAP_H |
size.cols, size.rows |
MAP_TILE_W, MAP_TILE_H |
tileSize.w, tileSize.h |
MAP_BG(N) |
layer where name == "bg", data[] row-major |
MAP_FG(N) |
layer where name == "fg" (optional) |
MAP_COLL_COUNT, MAP_COLL(N) |
every tile id whose tilesets[*].tiles[id].solid == true |
MAP_OBJ_COUNT |
total objects across object layers |
MAP_OBJ_TYPE$/KIND$/X/Y/W/H/ID(N) |
parallel arrays per object |
MAP_TILESET_COUNT |
number of tilesets |
MAP_TILESET_ID$(N) |
tilesets[N].id |
MAP_TILESET_SRC$(N) |
tilesets[N].src — pass to SPRITE LOAD slot, MAP_TILESET_SRC$(N), w, h |
MAP_CAM_START_X/Y |
camera.start.x/.y |
MAP_CAM_SCROLL_DIR$ |
camera.scrollDir |
MAP_CAM_SPEED_PX_PER_FRAME |
camera.speedPxPerSec / 60 (rounded) |
Schema details, including objects (type / kind / shape /
props), camera modes (auto / follow / room), and v1.1+
deferrals (RLE, animations, parallax, polygon shapes) are in the
map format spec.
Migrating from a builder to JSON
The examples/rpg/ MVP-2 game ships both. The migration was
mechanical:
- Generate the JSON from the existing builder. Run a one-shot
Python or BASIC script that imitates each
MAP_BG(R*MAP_W+C) = …line, builds the same flat int array, and emits a JSON object alongside the source. (Seeexamples/rpg/level1_overworld.jsonfor the canonical output.) - Drop the
#INCLUDEof the builder. - Add the tileset DIM lines (
MAP_TILESET_ID$,MAP_TILESET_SRC$). - Replace
LoadOverworld()withMAPLOAD "level1_overworld.json". - (Optional) remove the builder
.basfile from the runtime path; keep it in the repo as authoring reference, or delete it entirely once you trust the JSON.
Engine code (HandleInput, HandleInteract, CheckDoor,
UpdateCamera, render functions) needs zero changes — it
reads the same MAP_* globals either way.
When the BASIC builder still wins
- Procedural levels. A roguelike that re-rolls the dungeon
every run wants
RND()andFORloops, not a JSON file. Build the map in BASIC each time you enter a level; reach forMAPLOADonly for hand-authored set pieces. - Tiny maps. A two-screen tutorial that fits in 30 lines of BASIC isn't worth a separate file — keep it inline.
- Live-edit instrumentation. If you're tuning collision flags or enemy placements while running, mutating BASIC variables (or re-running the program) can be tighter than re-saving JSON.
For everything else — overworld + dungeon room sets, shooter scroll
data, anything you'd hand to an external editor someday —
MAPLOAD is the canonical path.
See also
- RPG tutorial — gameplay vocabulary that goes inside the
objlayer: spawns, doors, NPCs, loot, weapons, spells, traps, power-ups, McGuffins, enemies, AI presets, attack patterns, boss phases. map-format.md— the v1 schema spec, decision log, and v1.1+ deferral list.overlay-plane.md— HUD layer composited above tiles (used by the Zelda-lite MVP for its dialog box).examples/rpg/rpg.bas— the production user; loads two JSON levels, swaps tilesets, walks the sameMAP_*arrays the v0 shooter MVP did.