Worlds, animation & effects¶
This page takes the two-rectangle game from the first tutorial and turns it into a world. You will build a tile map bigger than the window, add a camera that follows the player, animate from game ticks, add art and sound, and keep it all working on machines big and small. By the end you will know how a game built with GX (d/BASIC's library for 2D games: scenes, sprites, tilemaps, cameras, collision) is organised.
The whole discipline fits in one line:
GX remembers what the world is. Intents ask the machine to show it.
An intent is a named request such as "fill this rectangle" or "play this tone" that the machine carries out however it can.
Build a map from ordinary data¶
In the current GX core, a tilemap is a plain one-dimensional INTEGER array, numbered from zero, with each row stored after the one before it. Forty columns by twenty-four rows needs exactly 960 words.
IMPORT gx
CONST MAP_COLUMNS = 40
CONST MAP_ROWS = 24
DIM SHARED Map(0 TO 959) AS INTEGER
DIM Col AS INTEGER
DIM Row AS INTEGER
FOR Row = 0 TO MAP_ROWS - 1
FOR Col = 0 TO MAP_COLUMNS - 1
CALL GXTileSet(Map(), MAP_COLUMNS, MAP_ROWS, Col, Row, 1)
NEXT Col
NEXT Row
' Build a wall with one doorway.
FOR Row = 3 TO 20
IF Row <> 12 THEN CALL GXTileSet(Map(), MAP_COLUMNS, MAP_ROWS, 18, Row, 2)
NEXT Row
Tile values are game meanings, not picture handles. Here 1 is ground and 2 is wall. A renderer might show each as rich art, coloured blocks, monochrome patterns, or symbols in an editor; the map itself stays the same.
Use GXTileIndex when a tool needs the position in the array, and GXTileAt when the game needs the value:
NextTile = GXTileAt(Map(), MAP_COLUMNS, MAP_ROWS, NextX, NextY)
IF NextTile = 2 THEN PRINT "A WALL BLOCKS THE WAY"
An invalid coordinate gives the documented safe result rather than reading outside the array.
Let a camera follow the action¶
With each map cell 16 × 16 pixels, the world is 640 × 384 logical pixels. The window shows only 320 × 192, so the camera has somewhere to go.
DIM SHARED Camera(0 TO 1) AS INTEGER
CALL GXCameraFollow(Camera(), HeroX, HeroY, 640, 384, 320, 192)
ScreenX = HeroX - GXCameraX(Camera())
ScreenY = HeroY - GXCameraY(Camera())
The camera centres on its target, then stops at the world's edges. World coordinates belong to the game state; screen coordinates exist only while painting.
That gives you a compact rule worth keeping:
Never rewrite every entity when the camera moves. Move the viewpoint, not the world.
Draw only the visible tiles¶
A small machine should not look at all 960 cells to draw a window twelve rows tall. Work out the visible range of cells from the camera and draw just that window.
CamX = GXCameraX(Camera())
CamY = GXCameraY(Camera())
FirstCol = CamX \ 16
FirstRow = CamY \ 16
LastCol = FirstCol + 20
LastRow = FirstRow + 12
FOR Row = FirstRow TO LastRow
FOR Col = FirstCol TO LastCol
Tile = GXTileAt(Map(), MAP_COLUMNS, MAP_ROWS, Col, Row)
PaintX = Col * 16 - CamX
PaintY = Row * 16 - CamY
IF Tile = 1 THEN INTENT GFX.FILL_RECT(PaintX, PaintY, 16, 16, 2081)
IF Tile = 2 THEN INTENT GFX.FILL_RECT(PaintX, PaintY, 16, 16, 16904)
NEXT Col
NEXT Row
In a real frame, place that loop between GFX.BEGIN_FRAME and GFX.PRESENT, and set a clip to the viewport first. A richer GX route can turn the same idea into a single "draw this tilemap" request; the visible-range loop remains a useful, predictable fallback and a good debugging tool.
Animate from ticks, not delays¶
An animation frame is a pure question about time:
That selects frames 12 through 15, holding each for six game ticks. It does not sleep, draw, or change an entity. That matters because waiting inside DRAW would block input and punish every platform.
Keep a game tick in your state, advance it once per accepted turn or frame event, and ask for the frame while painting:
CALL GXSceneAdvance(Scene())
Tick = Scene(GX_SCENE_TICK_AT)
Frame = GXAnimationFrame(12, 4, Tick, 6)
The image system can map Frame to a region of a checked sprite sheet. On a compact screen, the same frame can select a small indexed or monochrome version.
Give collision layers meaning¶
Tile collision and entity collision answer different questions:
GXTileAtasks what kind of place the player is entering;GXEntitiesOverlapasks whether two live rectangles overlap; and- your game decides whether that means blocked movement, damage, treasure, dialogue, or a finish line.
OldX = GXEntityX(Actors(), HERO)
OldY = GXEntityY(Actors(), HERO)
CALL GXEntityVelocity(Actors(), HERO, DX, DY)
CALL GXEntityStep(Actors(), HERO)
NewX = GXEntityX(Actors(), HERO)
NewY = GXEntityY(Actors(), HERO)
TileX = NewX \ 16
TileY = NewY \ 16
IF GXTileAt(Map(), MAP_COLUMNS, MAP_ROWS, TileX, TileY) = 2 THEN
CALL GXEntitySpawn(Actors(), HERO, OldX, OldY, 12, 12, 1)
END IF
Respawning is a simple, core-level way to put the geometry back. A fuller controller can offer a position setter and preserve the rest of the entity's state.
Add art without moving the game into the renderer¶
Package art under stable logical names. Load it once through the object that will own it, then draw by name and geometry. Do not reopen a file or decode a PNG inside each frame.
For small generated graphics, GFX.BLIT_RESOURCE accepts a bounded array from your program. For reusable game art, the host keeps a checked copy close to the screen. That distinction is why a 6502 (the processor in the Atari 800, the Commodore 64 and the Apple II) can ask for a large sprite without pushing the pixels across its bus every frame.
How art adapts is the host's job, not yours:
| You supply | A capable host may use | A limited host may use |
|---|---|---|
| one logical hero image | high-density RGBA or RGB565A1 | an indexed or one-bit version |
| one font role | a browser or desktop outline font | a hinted bitmap font built into the device |
| one tile identity | a GPU or compositor tile from an atlas | a palette tile or a pattern |
The host may lower the fidelity. It may not silently substitute a different logical asset.
Add sound as an event¶
Do not synthesise a waveform in the movement loop just because an old sound chip once required it. Ask for the sound event:
IF GXEntitiesOverlap(Actors(), HERO, BEACON) THEN
INTENT AUDIO.NOTE_ON(0, 880, 208, 1, 12)
Won = -1
END IF
A browser or desktop can play a polished voice. A classic d/OS machine (d/OS is the operating system that brings modern software to 8-bit home computers such as the Atari 800, the Commodore 64 and the Apple II) can map it to a native or mixed voice that the host allows. If sound is optional, the game stays playable when the host declines the audio request and says why; if sound is essential, declare that capability before launch.
Recorded effects and music use the bounded PCM lifecycle described in Resources, Images & Sound. Open outside DRAW, fill the buffer ahead of time, start, watch for partial writes, and close on every exit path.
Use events for controls, snapshots for action¶
Menus and forms are naturally event-driven. Fast games also benefit from a single input snapshot per frame: every button and axis describes the same instant. Where GX is heading, keyboard, controller, paddle, and pointer sources all map into that one stable game view, while d/BASIC window events stay available for pause screens and editors.
Bind actions, not hardware:
| Action | Possible bindings |
|---|---|
| Move left | Left Arrow, A, controller axis, joystick direction |
| Jump | Space, controller south button, joystick trigger |
| Pause | Escape, menu button, window command |
"Jump" belongs in game logic. "USB button 1" belongs in a host profile.
A world-building checklist¶
- Keep world positions independent of camera positions.
- Store tile meanings, not renderer handles, in the map.
- Update the state once per accepted turn.
- Choose animation frames from ticks; never sleep in paint code.
- Load reusable art and sound once and keep the object that owns them.
- Ask intents for whole jobs that the host can accelerate.
- Design an honest simpler version for limited colour, sound, or memory bandwidth.
- Test the paths where the host declines as carefully as the richest one.
Next: GX Engine Reference →