Skip to content

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:

screen position = world position - camera position

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:

Frame = GXAnimationFrame(12, 4, Tick, 6)

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:

  • GXTileAt asks what kind of place the player is entering;
  • GXEntitiesOverlap asks 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

  1. Keep world positions independent of camera positions.
  2. Store tile meanings, not renderer handles, in the map.
  3. Update model state once per admitted turn.
  4. Select animation frames from ticks; never sleep in paint code.
  5. Load reusable art and sound once and retain the owning adapter.
  6. Ask Intents for whole jobs that a composed head can accelerate.
  7. Design an honest lower representation for limited colour, sound, or memory bandwidth.
  8. Test refusal paths as carefully as the richest route.

Next: GX Engine Reference →