Reviewing My Own High School Code: Star Fighter in QBasic

Reviewing My Own High School Code: Star Fighter in QBasic


I wrote Star Fighter in high school, around the year 2000. QBasic 4.5, DOS, a blue full-screen editor, and zero formal training in software engineering. Looking at this code today as a senior engineer is an exercise in humility, and occasionally, genuine surprise at what high-school me managed to pull off with so few tools.

The game is now open-source, and you can play it right in your browser. But this article isn’t about nostalgia. It’s a technical retrospective: an honest breakdown of the engine, the architecture, what works, what doesn’t, and what I’d change if I were writing it today.

Star Fighter QBasic gameplay - EGA graphics, side-scrolling space combat

The Game Engine at a Glance

Star Fighter is a side-scrolling space shooter: three missions, boss fights, power-ups, a difficulty system, and a persistent high-score file. The entire thing is one file, Fighter.bas, roughly 1,200 lines of QBasic.

The main loop is dead simple:

DO
  PCOPY 1, 0: Clr
  Keys 1
  Fps
  Putfire
  Upgrade
  Stars
  Planes
  Ships
  Keys 2
  Oponnentfire
LOOP UNTIL lives < 0 OR fin = 4 OR fin = 5

Every frame: copy the back buffer to the screen, clear the back buffer, process input, update entities, render. It’s a textbook game loop, and I didn’t know the term “game loop” when I wrote it.

Technical Strategy: What I Got Right

Double-Buffered Rendering

The game uses SCREEN 9, 0, 1, 0: EGA mode at 640×350 with 16 colors, drawing to page 1 while displaying page 0. Each frame calls PCOPY 1, 0 to swap the buffers. This eliminates flicker entirely. For a high schooler working in QBasic, this was the single most important architectural decision in the entire project. Without it, the game would be an unplayable mess of tearing artifacts.

Direct Hardware Keyboard Input

QBasic’s built-in INKEY$ function reads one key at a time from the keyboard buffer. That’s fine for menus, but useless for a game where you need to move diagonally while firing. I solved this by reading the keyboard controller directly:

SUB Keys (choose)
  sel = INP(&H60)

INP(&H60) reads the raw scan code from the 8042 keyboard controller, including key-down and key-up events (scan code + 128 = key released). The Keys subroutine maintains a two-slot array (key1(1) and key1(2)) to track up to two simultaneous key presses, with conflict resolution logic to handle opposing directions. This is a hand-rolled input manager, built on direct port I/O, in a language that has no concept of event-driven input.

AND/XOR Sprite Masking

The sprite rendering uses the classic DOS transparency technique:

PUT (xship(X), yship(X)), mship, AND
PUT (xship(X), yship(X)), ship, XOR

First, a mask image is ANDed onto the screen to punch a transparent hole (black pixels in the mask preserve the background, white pixels clear it). Then the sprite is XORed on top. This gives proper transparency without a dedicated alpha channel, and it’s the only way to do it in QBasic’s PUT statement.

SoundBlaster OPL2 Direct Register Writes

The sound system writes directly to the OPL2 FM synthesis chip on the SoundBlaster card:

SUB PlayFX (num)
  FOR i = 1 TO 15
    OUT &H388, ASC(MID$(Snd(num), (i * 2) - 1, 1))
    OUT &H389, ASC(MID$(Snd(num), (i * 2)))
  NEXT i
END SUB

Sound data is stored in a packed string array (Snd()), loaded from a binary .SND file. Each sound effect is 30 bytes of raw OPL2 register/value pairs. The SBdetect subroutine probes ports &H210 through &H280 to auto-detect the card. This is real bare-metal programming: no sound library, no mixer API, just OUT instructions to hardware ports.

Data-Driven Level Design

Enemy wave patterns aren’t hardcoded. They’re loaded from .DAT files at runtime:

OPEN path$ + "level" + LTRIM$(STR$(level)) + ".dat" FOR INPUT AS #1

Each file defines ship positions, types, and spawn timings. The Scroller-style logic in the Ships subroutine scrolls enemies into view based on a frame counter (times * 5), creating the wave patterns. This separation of data from logic is a solid architectural instinct for a beginner.

Architectural Weaknesses: What I’d Fix Today

Everything Is Global State

Every single game variable (player position, enemy arrays, bullet states, score, lives) is declared as COMMON SHARED at the module level. There are 30+ global variables. The Keys subroutine directly mutates xpos and ypos. The Chkdeath subroutine directly mutates ennemy(), pts1, and energy. Nothing is encapsulated. Every subroutine can (and does) touch anything.

To be fair, QBasic doesn’t have classes, structs, or even user-defined types in a practical sense. COMMON SHARED was the only way to share state between subroutines. But even within that constraint, I could have used fewer globals by passing more parameters and returning values.

Collision Detection Is Brute-Force

The Chkdeath subroutine iterates over all bullets for every enemy, every frame. There’s no spatial partitioning, no early-out optimization, no bounding-box pre-check before the expensive coordinate comparisons. With 200 possible ships and 10 bullets, that’s potentially 2,000 collision checks per frame. On a 486 running QBasic, that hurts.

The Font System Is… Something

Every letter of the alphabet is stored in its own integer array (cha1 through cha26), loaded from individual .GPH files. The Printfont subroutine uses a 38-branch SELECT CASE to map characters to arrays. A lookup table, even just an array of arrays, would have been cleaner. But QBasic doesn’t support arrays of arrays, so this was actually the most straightforward approach available. Still painful to look at.

No Frame-Rate Independence

The game loop has an FPS counter (Fps subroutine), but the movement logic isn’t tied to elapsed time. It’s tied to frame count. xpos = xpos - shipspd moves the same number of pixels per frame regardless of how fast the machine is. On a fast Pentium, the game runs at 60+ FPS and everything moves smoothly. On a slow 486, frames take longer but ships still move shipspd pixels per frame, so the game just runs slower. There’s no delta-time compensation.

Hardcoded Path

path$ = "C:\FIGHTER\"

The game expects all assets to be in C:\FIGHTER\. No relative paths, no current-directory detection. This is the kind of thing that works perfectly on your own machine and breaks on everyone else’s. (This is actually the reason the DOSBox-X integration mounts the game directory at C:\FIGHTER.)

What I’d Do Differently

If I were rewriting this today (still in QBasic, same constraints), I’d:

  1. Use TYPE for entity state. QBasic does support TYPE...END TYPE for simple record types. Enemy ships, bullets, and upgrades should each be a typed record instead of parallel arrays.
  2. Spatial hashing for collision. Divide the screen into a grid, bin entities by cell, only check collisions within the same cell.
  3. Delta-time movement. Use TIMER to calculate elapsed time per frame and scale all movement by it.
  4. Relative paths. Use CURDIR$ or just drop the path prefix entirely.
  5. Reduce global state. Pass entity arrays as parameters to subroutines instead of relying on COMMON SHARED for everything.

Play It Now

Thanks to js-dos (DOSBox compiled to WebAssembly), the original FIGHTER.EXE runs directly in your browser, complete with SoundBlaster emulation. It’s the real compiled executable, running through DOSBox-X in a WASM sandbox.

Play QBasic Star Fighter →

What Came Next

About ten years later, I rebuilt Star Fighter from scratch in JavaScript, with a very different set of architectural decisions, a very different era of web development, and a very different set of mistakes.

Source Code

The full QBasic source, all game assets, and the compiled executable are on GitHub:

TinkerNorth/starfighter-qbasic

Licensed under LGPL-3.0. Contributions welcome.