Porting Flapper to Boriel BASIC — a short retrospective

2026-05-02 by juntelart

Porting a compact Sinclair BASIC to Boriel BASIC is a lesson in constraint translation: the gameplay ideas are tiny and clear, but timing, memory layout and a few helper primitives change how you implement them. I focused on three goals:

  • preserve the original feel (timing, pipe rhythm)
  • keep code modular and readable for learners
  • make minimal runtime changes where Boriel behaved differently

Below I narrate the main decisions and share small code samples so you can follow along or reuse the techniques.

Flapper gameplay

Why this structure? (short answer)

Keeping input, physics, rendering and collision logically separated made the porting work mostly about tuning constants and a couple of small platform-specific changes (not rewriting gameplay). That separation also makes the code easier to showcase in a blog post: each concept maps to a small function.

The per-frame heartbeat (in plain words)

The game runs a simple, predictable frame loop: read input, update physics, scroll the world, draw the bird, then check collisions and scoring. That ordering keeps behavior deterministic — which helped a lot when I slowed the Boriel build to match Sinclair timing.

Practically speaking, the loop is short and readable and composed of these small helpers: screenSync(), readKeyboard(), gravity(), scroll(), redrawBird(), checkScore(), checkBirdCollision().

Key modules and what changed

  • definitions.bas — tuning knobs: gravity, pipe geometry and display attributes live here.
  • physics.bas — fixed-point motion; tiny math, easy to tweak.
  • draw.bas — here I separated attribute (color) bytes from pixel data; that choice simplified collision and made erasing/drawing the bird straightforward.
  • collision.bas — attribute-based collision: fast and conceptually simple.

I rarely had to rewrite algorithmic logic; most work was adjusting timings and ensuring the attribute buffer writes matched Boriel's memory mapping.

Scoring & pipe rhythm

Pipes are generated by two interleaved slots using a single worldCol counter. This single timing source keeps pipe drawing, scoring and floor patterning in sync. The result is a predictable rhythm that felt right after a couple of tuning passes.

Flapper gameplay

Rendering and collision — the practical choice

I chose attribute-based collision so we could avoid per-pixel masks on the bird. The bird writes pixels only, leaving attributes unchanged; collision is simply "does the 2×2 attribute box at the bird's position contain anything but ATTR_SKY?".

This design is cheap, robust and very easy to explain to someone reading the code for the first time.

Flapper gameplay

Performance note

Boriel timings required a pragmatic tweak: the Boriel build in this repo is slowed by a factor of 4 so gameplay speed matches expectations from the original. That change is purely a runtime tuning to preserve player experience.

Final thoughts — lessons for porters

  • Keep gameplay logic separate from rendering and platform glue.
  • Use attribute-based collision on systems with character/attribute layers; it's often simpler and faster.
  • Tune constants last — most porting bugs are timing-related, not algorithmic.

Code examples

Below are small, copy-pastable examples extracted from the codebase to illustrate common tasks: showing the play screen (game loop), collision detection, and world scrolling.

Show a screen (game loop)

Sub showPlayGameScreen(clearScreen As Ubyte)
  initGame(clearScreen)
  Do
    screenSync()
    readKeyboard()
    preserveYPosition()
    gravity()
    scroll()
    redrawBird()
    checkScore()

    If checkBirdCollision(birdX, Int(birdYPos)) Then
      showGameOverScreen()
    End If
  Loop
End Sub

This sequence is the per-frame update: input → physics → world scroll → render → collision/score.

Collision detection (attribute-based)

Function checkBirdCollision(bx As Ubyte, by As Ubyte) As Ubyte
  Dim attrBuf(3) As Ubyte
  getPaintData(bx, by, 2, 2, @attrBuf(0))
  If attrBuf(0) <> ATTR_SKY Then Return 1
  If attrBuf(1) <> ATTR_SKY Then Return 1
  If attrBuf(2) <> ATTR_SKY Then Return 1
  If attrBuf(3) <> ATTR_SKY Then Return 1
  Return 0
End Function

Collision is computed by reading the 2×2 attribute bytes at the bird's tile and checking for anything different than ATTR_SKY.

World scroll (attributes + last column)

Implementation detail: when scrolling the playfield we shift attribute bytes row-by-row (using MemMove per row) instead of moving the whole attribute memory at once. Doing the move per row avoids transient "garbage" appearing in the right-most column due to overlapping memory regions during the shift.

Sub scrollPlayfieldAttrs()
  Dim row As Ubyte
  Dim src As UInteger = $5821
  Dim dst As UInteger = $5820
  For row = 0 To 23
    MemMove(src, dst, 31)
    src = src + 32
    dst = dst + 32
  Next row
End Sub

Sub paintLastColumn()
  Dim wc As Ubyte = worldCol Mod PIPE_PERIOD
  Dim attribute As Ubyte = ATTR_PIPE
  Dim pipeLastCol As Ubyte = PIPE_WIDTH - 1

  If wc < PIPE_WIDTH Then
    If wc = pipeLastCol Then attribute = ATTR_PIPE_SHADOW
    writePipeColumn(31, pipeGap(0), attribute)
    Return
  End If

  If wc >= PIPE_SPAWN_INTERVAL Then
    If wc < PIPE_SPAWN_INTERVAL + PIPE_WIDTH Then
      If wc - PIPE_SPAWN_INTERVAL = pipeLastCol Then attribute = ATTR_PIPE_SHADOW
      writePipeColumn(31, pipeGap(1), attribute)
      Return
    End If
  End If

  writeSkyColumn(31)
End Sub

Sub scroll()
  scrollPlayfieldAttrs()
  paintLastColumn()
  worldCol = worldCol + 1
End Sub

The code shifts the attribute buffer left, paints a new right-most column (pipe or sky) based on worldCol, and increments the global column counter.

Drawing / erasing the bird (pixels only)

Sub eraseBird(bx As Ubyte, by As Ubyte)
  putChars(bx, by, 2, 2, @blankSprite(0))
End Sub

Sub drawBird()
  putChars(birdX, Int(birdYPos), 2, 2, @sprite0(0))
End Sub

Sub redrawBird()
  waitretrace
  eraseBird(birdX, birdOldY)
  drawBird()
End Sub

Note: drawing only writes pixel bytes; attributes are preserved for correct collision detection. A waitretrace is used before redrawBird() to avoid sprite flicker when erasing and drawing the sprite.

More information

For more information: github.com/rtorralba/flapper-boriel

Download the game


Back to posts