Worlds, animation & effects¶
The first tutorial moved two rectangles. Now we will make the model feel like a world: a tile grid larger than the window, a camera that follows the player, animation driven by game ticks, and effects requested through Intents.
The useful discipline is simple:
GX remembers what the world is. Intents ask the machine to present it.
Build a map from ordinary data¶
A current GX core tilemap is a zero-based rank-one INTEGER array in row-major order. Forty columns by twenty-four rows need 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 image handles. Here 1 is ground and 2 is wall. A renderer might show each as rich art, coloured blocks, patterned monochrome, or editor symbols; the map remains the same.
Use GXTileIndex when a tool needs the linear address, and GXTileAt when game logic 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 produces the documented safe result rather than reading outside the carrier.
Let a camera follow the action¶
The world is 640 × 384 logical pixels when each map cell is 16 × 16. The viewport is only 320 × 192, so the camera has somewhere to travel.
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 centers the focus, then clamps to the world edges. World coordinates belong to the model; 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 inspect all 960 cells for a twelve-row viewport. Derive the visible cell range from the camera and draw that bounded 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
For a real frame, place that loop between GFX.BEGIN_FRAME and GFX.PRESENT, and set a viewport clip first. A richer GX route can lower the same idea to one tilemap presentation request; the visible-range logic is still a useful, deterministic fallback and debugger.
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 mutate an entity. This matters because waiting inside DRAW would block input and punish every platform.
Keep a game tick in your model, 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 validated sprite sheet. On a compact head, the same frame can select a small indexed or monochrome representation.
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
The respawn call is a simple core-level way to restore geometry. A fuller controller can expose a position setter and preserve additional entity state.
Add art without moving the game into the renderer¶
Package art under stable logical identities. Load it once through an owning resource adapter, then draw by identity and geometry. Do not reopen a file or decode a PNG inside each frame.
For small generated graphics, GFX.BLIT_RESOURCE accepts a bounded caller array. For reusable game art, the runner keeps a validated representation close to the composed head. That distinction is why a 6502 can ask for a large sprite without pushing the pixels across its bus every frame.
The adaptation policy is part of the runner:
| Author supplies | Rich route may use | Constrained route may use |
|---|---|---|
| one logical hero image | high-density RGBA or RGB565A1 | indexed or one-bit variant |
| one font role | browser/desktop outline font | hinted resident bitmap face |
| one tile identity | atlas-backed GPU/compositor tile | palette tile or pattern |
The runner may lower fidelity. It may not silently substitute a different logical asset.
Add sound as a semantic event¶
Do not synthesize a waveform in the movement loop merely 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 render a polished voice. A classic D/OS route can map it to an admitted native or mixed voice. If sound is optional, the game remains playable after a named audio refusal; if it is essential, declare that capability before launch.
Recorded effects and music use the bounded PCM lifecycle described in Resources, Images & Sound. Open outside DRAW, prefill, start, observe 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 one frame-coherent input snapshot: all buttons and axes describe the same sampled moment. The full GX direction maps keyboard, controller, paddle, and pointer sources into that stable game view while D/BASIC window events remain available for pause screens and editors.
The application should 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 runner profile.
A world-building checklist¶
- Keep world positions independent of camera positions.
- Store tile meanings, not renderer handles, in the map.
- Update model state once per admitted turn.
- Select animation frames from ticks; never sleep in paint code.
- Load reusable art and sound once and retain the owning adapter.
- Ask Intents for whole jobs that a composed head can accelerate.
- Design an honest lower representation for limited colour, sound, or memory bandwidth.
- Test refusal paths as carefully as the richest route.
Next: GX Engine Reference →