Design review · PicoMite MMBasic · September 2026

Exile on the PicoComputer 3

A design review for porting Peter Irvin and Jeremy Smith's 1988 BBC Micro Exile to the PC3 in MMBasic: what the original actually is, measured against what the PicoMite gives us, the decisions that shape the port, and a phased plan built on the Chuckie Egg and Thrust precedents.

Right: the landing site at the start of the game, 30 by 12 squares. Not a screenshot: every pixel was produced by running the reverse-engineered world generator, the palette logic and the sprite sheet, then scaling 2:1 for square pixels.

The Pericles standing on its legs at the surface, with the caves opening beneath it
The argument

Exile is not a rendering problem. It is a simulation problem, and the simulation is documented

Thrust was a small game with one big idea, and the port was cheap because everything that made it hard on a 6502 could be deleted. Exile is the opposite shape. Its rendering tricks — the circular screen buffer, the self-modifying sprite plotter, the raster interrupt that paints the water — are all deletable too, and the PicoMite's tile engine draws its world in one call. But the thing that made Exile a masterpiece is the thing that cannot be deleted: a physics kernel that treats every one of sixteen live objects the same way, a creature model with moods, stimuli, line of sight and memory, and a world that is a pure function of its coordinates. That is where the port's effort goes, and it is where the risk is.

The good news is that the whole of it is written down. The level7 disassembly annotates every routine and every table; Tom Seddon made it buildable; Jon Saffron transcribed the landscape generator into C#; and the 2026 bbcexile.com rebuild has re-implemented it bit-identically in TypeScript, validating whole frames against a 6502 interpreter. There is no guessing to do about how Exile works. There is a great deal of transcription to do.

Three measurements were made for this review, before any BASIC was written, because each one changes the design:

Decisions for Peter

Each is argued in the architecture section; the recommendation is listed first.

  1. D1 — Loading the world into TILEMAP. Done TILEMAP CREATE reads its map only from DATA statements, and 65,536 values will not fit in program memory. TILEMAP LOAD file$, id, flashSlot, tileW, tileH, tilesPerRow now reads a text file of width, height and tile numbers instead; a 256 × 256 map loads in 0.5 s on the PC3. Built and tested the same day (Testfiles/TilemapLoadTest.bas, and Bas/breakout.bas now loads its brick field from breakout.map).
  2. D2 — Sound. Done Exile drives the sound chip directly from the vsync interrupt through two envelopes per channel, one for volume and one for frequency, with stages and loops, which PLAY BBC ENVELOPE cannot express. No new primitive is needed: the game steps its own envelope tables and hands each 20 ms step to PLAY BBC SOUND as a flushed 50 ms note, so the sound stays self-limiting. The teleport sound was played that way against an exact-frequency reference (Testfiles/ExileTeleportSound.bas) and the quarter-semitone rounding, at most 12 cents, was not audible. Measured on the PC3, PLAY BBC SOUND is also the cheaper engine: 17 µs a call like PLAY SOUND, but 8.5% of the interpreter with four channels sounding against 19%.
  3. D3 — Which Exile. Recommend the enhanced (sideways RAM) version as the reference: same engine, double-height view, seven speech samples, and it is the version the bit-identical rebuild validates against. Nothing in it costs us anything the standard version would not.
  4. D4 — Screen geometry. Done A 256 × 240 view, 8 × 7½ squares, the enhanced BBC version's width, beside a 64-pixel panel for energy, the weapon in use and the pockets. Taken for the creature population as much as for the drawing: spawns and promotions happen when a tile is plotted, so the visible area sets how many of the sixteen slots are alive, and 60 squares keeps the game as balanced where 75 would keep a quarter more creatures awake. Measured, the narrower view is also 0.6 ms a frame cheaper in the worst region (5.1 against 4.5 ms for the whole frame), and the panel costs nothing because it is drawn once and never cleared. A native 256-line mode on the 1024 × 768 timing was considered and set aside: the XGA line generator maps a colour nibble to a word of four RGB332 pixels, so only 4 × horizontal expansion is cheap, and 4 × 3 makes a BBC pixel 2.67:1 against the original's 2.13:1; 3 × horizontal would mean unaligned packing in a 17.7 µs line routine. The half square lost top and bottom at 240 lines is not worth that. Should MODE 6 (256 × 256 at 4 × 3) ever exist, it is a one-constant change here.
  5. D5 — Object sprites. Done A third flash slot holds every sprite in every palette an object type uses, in all four orientations: 213 sprite-and-palette pairs, 852 images, packed into 64 KB, so each object is one BLIT FLASH a frame with the key colour transparent and no RAM buffers. Verified on the PC3: a screenshot of sixty sprites and two of them in all four orientations matches the Python reference with 0 pixels differing.
  6. D6 — Timing. Recommend 25 Hz lockstep: one simulation tick per drawn frame at 40 ms. The constants are integer deltas per tick and gates like "every sixteen ticks", so they must not be rescaled.
  7. D7 — The world. Recommend generating it offline: a Python port of get_tile validated square-for-square against the C# generator, shipped as a 128 KB file. Generating it on the board in BASIC would take minutes.
Platform

What the PC3 gives us

FactValueSource
Frame budget40 ms at Exile's own 25 Hz; Thrust and Chuckie Egg hold 33 msMeasured on this board
ScreenMODE 2, 320 × 240, 16 colours (RGB121); the eight BBC colours are all in the paletteSame mode as the other ports
Tile engineTILEMAP: 4 maps, 2 bytes per cell, tiles to 256 px, viewport draw in one C call with sub-tile scroll and a transparent colour, SET for live changes, attribute collisionTILEMAP_User_Manual.md (RP2350 only, RGB121 only)
Tileset storage3 flash image slots, each the size of program memory (144 KB on the PC3): 287 tiles of 32 × 32 per slotMAXFLASHSLOTS, MAX_PROG_SIZE in configuration.h
Sprites64 RAM buffers with mirror on SPRITE SHOW; BLIT FLASH from a slot with transparency but no mirrorSPRITE and BLIT manuals
Double bufferingFRAMEBUFFER CREATE / WRITE F / COPY F, N; the copy costs 0.1 ms, or waits for the 60 Hz frame with ,BProven in Chuckie Egg, Thrust, Elite
InputKEYDOWN, up to six simultaneous keysEstablished; Exile needs four at once at most
SoundPLAY BBC SOUND / ENVELOPE on four channels; PLAY WAV for samplesV6.03.02b6
Program memory144 KB; Chuckie Egg is 53 KB, Thrust 66 KB of sourceMEMORY on the PC3
RAMPSRAM, 6 MB free: the 128 KB world map and every table are trivialMEMORY on the PC3
Interpreter speedThrust: 500 array accesses a step cost 7 to 9 ms; a full particle pool and 19 objects simulate in 17 to 20 msThrust phase 5 table

The number to keep in mind is the last one. Exile ticks sixteen objects and thirty-two particles, each with more logic than a Thrust limpet gun, and the frame it has to fit is only seven milliseconds longer. The renderer will be cheap; the interpreter is the budget.

The original

Five subsystems, all reverse-engineered

Exile shipped as about 26 KB of 6502 and data. The level7 disassembly of the standard version runs to 8,528 instructions (8,796 for the enhanced version) against 4,937 for Thrust, so the game is 1.7 times Thrust's code, and considerably more than that in behaviour, because Exile's code is table-driven and self-modifying where Thrust's is plain.

The world — a function, not a map

The planet is 256 × 256 squares and is never stored. get_tile(x, y) is about 500 bytes of code, 54 bytes of tables and 1,024 bytes of hand-drawn data, and it answers for any square on demand. A seven-instruction hash of the coordinates is the only entropy; from it the layers are stacked: sky above row &4E, rock texture, the surface, the ragged bedrock floor, roughly 8-square chambers, one-wide vertical shafts, sloping passages whose direction is a self-modified add or subtract, horizontal corridors, and finally furniture — nests, bushes, columns, pipes. The 1,024 mapped bytes are addressed by a second hash that admits exactly 1,024 of the 65,536 coordinates, with no collisions: the crashed ship, the equipment rooms and Triax's base read back through it.

Placeholders &00–&08 mean "ask the object system": a 254-entry tertiary table keyed on x alone resolves doors, switches, transporters, nests and their live state.

The renderer — 128 pixels wide

A custom 6845 mode 128 pixels wide, 256 tall in the enhanced version (16 KB at &4000) and 128 tall in the standard one, with 2:1 pixels so a 16 × 32 tile is square on a monitor. The 16 KB is a circular buffer and scrolling is a nudge of the start-address register, redrawing only the exposed strip. Every graphic in the game comes from one 128 × 81 two-bit sprite sheet of 2,592 bytes holding 125 sprites, drawn through a one-byte palette (top nibble picks the colour for logical 3, bottom nibble indexes a sixteen-entry table for 1 and 2) and flipped either way at plot time by self-modifying the loop direction.

Colours 8–15 are programmed identical to 0–7 and serve as a per-pixel priority flag: objects are plotted masked into 0–7 and skip any byte where a priority pixel already sits.

The physics — one kernel for everything

Sixteen primary slots are simulated each tick; thirty-two secondary slots remember position, type and a nibble of energy for things that went off screen; the tertiary table holds things fixed to a place. The player, a boulder, a bullet and Triax go through the same update: copy to zero page, look up weight and sprite extent, integrate velocity into position, collide with water and tiles, resolve support and wedging, run the type's behaviour, then apply accelerations to velocities with gravity folded in as a carry. Weight is three bits of the type flags and decides buoyancy, wind and what you can lift.

Positions are a square plus an 8-bit fraction; velocities are signed 8-bit fractions per tick, capped at ±64 — a quarter of a square.

The creatures — one kernel, forty behaviours

Around forty per-type update routines share a walking kernel with seven walking types (player, frogmen, imps, green slime, robots, worms) parameterised by five tables: steepest walkable slope, acceleration, weight shift, turn probability and jump probability. On top of that sits a mood in four states driven by ten stimulus types, a target with a "directness" that says how straight the path is, an avoid flag, a remembered target square, and line of sight that the water surface blocks. Nests spawn birds; feeding an imp yields a gift; whistles summon.

Ten object-type ranges set maximum energy (player &7F, flying enemies &FD, projectiles &3F) and four explosion types decide what happens at zero.

The sound — two envelopes per channel

The vsync interrupt steps, for each of four channels, a volume envelope and a frequency envelope, each a list of stages (a delta applied N times) with loops, then writes the 76489 directly: frequency = 4 MHz ÷ (32 × value) through a four-range remap so that lower numbers mean lower pitches. Forty-nine call sites pass a four-byte block: two envelope-and-start bytes for volume, two for frequency. The enhanced version adds seven 4-bit speech samples played from sideways RAM: "Welcome to the land of the Exile", "Ow!", "Ooh", "Destroy!", "Radio die".

The rest of the engine

Thirty-two particle slots and eleven particle types (plasma, jetpack, explosion, fireball, trail, engine, aim, mushroom spore, flask, water, wind), each a row of eleven bytes of lifetime, speed, colour, flags and randomness. Four independent waterlines by x-range, drawn by a mid-frame raster interrupt that recolours colour 0 cyan for one line and blue below, with "desired" levels that let Triax's lab flood. A random-tile event system that runs wind, water, mushrooms and nest spawning on whichever square it picks. An earthquake that shudders the CRTC. Thirty-nine key actions. A save that dumps the whole state to disc.

Exile by the numbers

ThingCountWhere
World256 × 256 squares of 16 × 32 pixelslandscape generation, &1715–&19A6
Mapped squares1,0241,024 bytes at &4FEC, a perfect bijection
Tertiary objects254 entries, matched by 439 squaresx-keyed tables at &05EF
Object types101&00 player to &64 invisible inert
Live objects16 primary + 32 secondaryphysics engine, &1A0B–&1E18
Sprites125 in 2,592 bytes128 × 81 sheet at &53EC
Tile types64&00–&0F handled, &10–&3F scenery
Particles32 slots, 11 types&0206
Sounds49 call sites, ~200 bytes of envelopes&1320–&149C, &2DB9
Key actions39&11F6
Tick rate25 Hz ceilingtwo vsyncs in consider_setting_crtc_start_address
Instructions8,528 (8,796 enhanced)counted in the level7 listing; Thrust is 4,937
Where the notes disagree: the bbcexile physics chapter says only "sixteen object slots"; the level7 text is explicit about the three lists and the promotion rules between them, and that is what the code does. And the C# generator names colours 0–7 "foreground" and 8–15 "background", the opposite of level7's convention; the plotter settles it — the 8–15 group is the one objects cannot overwrite.
Measurement

Every square of the planet, counted

Jon Saffron's ExileWorldGenerator is a line-by-line C# transcription of the 6502 landscape generator, the object-data overrides and the palette logic, with the sprite sheet and its tables. Compiled without its Windows Forms shell and driven from a small console harness (kept in Bas/exile_tools/census/), it walks all 65,536 squares and reports what a port has to store and draw. The two images in this document are rendered by the same code; the lower part of the header image is the caves the player first drops into.

The whole planet as a 256 by 256 map: sky, rock, tunnels, the ship, water at the bottom
The whole planet. Rock in blue-grey, open squares black, water below the four waterlines, the spaceship parts in yellow, features (nests, bushes, mushrooms, columns) in green. The red mark is the player's starting square at (&9B, &3B). Everything below the surface line is the generator's arithmetic.
SquaresCountShare
Rock and slopes38,34158.5%
Open (of which windy)26,213 (1,308)40.0%
Features: nests, bushes, pipes, mushrooms, columns8021.2%
Spaceship structure1800.3%
Hand-mapped1,0241.6%
Carrying a tertiary object4390.7%

Rock is earth, stone, their slopes, quarters and halves; open includes the placeholder types that resolve to doors, switches and transporters.

Tile variants: 413, in two flash slots

A TILEMAP tile index has to stand for one exact set of pixels, and Exile recolours the same sprite by position: stone changes palette every 16 rows, earth every 32, bushes pick one of four schemes from a position hash, mushrooms are red on floors and blue on ceilings. So the question is how many distinct (sprite, flip, palette) combinations the planet actually uses, and the answer is not the worst case of 64 types × 4 orientations × 34 palettes but 413.

MeasureValueConsequence
Distinct (type, orientation, palette)436Upper bound on tile indices
Distinct after merging identical sprites413The tileset
… of which empty15Tile index 0
Variants covering 99% of squares155The long tail is set pieces
Distinct palette bytes34Colours 1–3 through the pair table
Storage at 32 × 32 × 4 bpp401 tiles, 205 KBTwo slots of 287; the third is free for objects

Two tilemaps share one map: indices 1–287 live in slot 1 and the rest in slot 2, each map holding zero where the other has the tile, and both are drawn every frame. TILEMAP DRAW skips zero cells, so the second draw costs only the tiles it owns.

Priority: draw the tiles last

The plotter's rule is that an object pixel never lands on a priority pixel. Taking every one of the 413 variants through its palette and counting pixels by group gives 100,773 priority pixels against 1,611 plain ones. 392 variants are all priority, 3 are all plain, 3 mixed, 15 blank. The six with plain pixels are the water-stone tile in Triax's lab and a few spaceship fittings.

So the original's picture is reproduced almost exactly by ordering rather than by masking: fill the background, draw the objects, then draw the tilemap with colour 0 transparent so that objects show through the black of the tunnels and are covered by rock, bushes and pipes. The six exceptions can be drawn before the objects from a third, tiny tilemap, or ignored; either way there is no per-pixel test and no second copy of the tileset. This is the measurement that makes the renderer cheap.

Decisions

Architecture

Units: keep Exile's, map to the screen at the end

A square is 16 × 32 BBC pixels, a position is a square number and a 256th of a square, so there are 16 fractions to a pixel across and 8 down. Keep all of that as integers in MMBasic — one 64-bit integer per axis holding square × 256 + fraction — and the physics transcribes line for line. The screen mapping is applied only when drawing: one BBC pixel is 2 × 1 of ours, so a square is 32 × 32 pixels and world pixel (x, y) lands at (2x − camX, y − camY). Do not be tempted to rescale the constants; the same lesson as Thrust's elliptical angle tables applies to Exile's "very approximate" angle-to-vector routines, which are 256-step tables with known errors that the game was balanced against. Copy the tables, not the trigonometry.

Screen: 8 × 7½ squares and a panel D4

The enhanced BBC view is 8 squares wide and 8 tall; the standard version showed 8 × 4. At 32 × 32 a full 320 × 240 would show 10 × 7½, and it was the first recommendation, on the grounds that the BBC had no status display. The decision went the other way once the cost of the view size was understood: Exile spawns from a nest and promotes a turret when its tile is plotted, and demotes objects 4 or 12 squares off screen, so the visible area governs how many of the sixteen primary slots are alive, and each live creature is the most expensive thing in the tick. A 256 × 240 view plots 60 squares, the enhanced version's 64 near enough; 320 wide would plot 75. The 64-pixel panel beside it carries what the BBC could not afford, energy, the weapon and its charge, the pockets, and it is drawn once into the framebuffer and never cleared, so it costs nothing per frame. worldview.bas has the layout.

Timing: 25 Hz lockstep D6

The main loop increments a frame counter, updates every object, runs the events, and goes round again; the only pacing is that replotting the player waits until two vsyncs have passed. So the tick was 25 Hz on a quiet screen and slower on a busy one. Gravity is +1 per tick, inertia is −1 every sixteen ticks on a counter offset per object slot so that not everything settles on the same frame, water drag is ×⅞ every four ticks, the wind tiles rotate once in 64 ticks. None of that survives rescaling. Run one tick per frame at 40 ms, and if the interpreter cannot keep up on a busy screen the game slows down exactly as the original did.

The world: generate offline, load once D7 D1

The generator is a pure function, so the whole map can be produced on the PC and shipped as a file: 65,536 cells of tile-variant index, 128 KB as 16-bit values, plus a parallel byte per square of tile type for the game logic (obstruction profile, handler, wind, water). A Python port of get_tile is a day's work and is checked square-for-square against the C# generator, which is itself checked against the game. Generating on the board is out: the routine is a few hundred BASIC statements per square, and 65,536 squares would take minutes.

Getting the file into a TILEMAP was the one thing the firmware could not do when this review was drafted: TILEMAP CREATE reads exactly cols × rows values from DATA, and 65,536 DATA values are more than program memory. That is now closed. TILEMAP LOAD file$, id, flashSlot, tileW, tileH, tilesPerRow reads a plain text file — width, height, then the tile numbers, with comments and any mix of separators — through the same allocation as CREATE, and a 256 × 256 map takes 476 ms on the PC3. So gen_world.py writes one .map file per tilemap slot and the game loads them at start. The windowed-map fallback that this section used to describe is no longer needed.

Doors opening, nests emptying, switches thrown and mushrooms picked are single-cell changes and go through TILEMAP SET in either scheme. Tile collision uses the game's own obstruction profiles, eight heights per tile type, looked up from the per-square type byte; the tile engine's attribute collision is a convenience for the quick tests, not the physics.

Objects: a third slot and BLIT FLASH D5

An object is drawn as a sprite in the palette its type table gives it, flipped horizontally to face left and vertically when hanging or swimming inverted. The 112 non-tile sprites total 10,842 BBC pixels; rendered at 2 × 1 in every palette an object type uses and in all four flips, the sheet is about 90 KB, inside one slot. Each object then costs one BLIT FLASH a frame with colour 0 transparent, sixteen a frame at most, no buffer bookkeeping. The alternative, a least-recently-used cache of the 64 RAM sprite buffers keyed by (sprite, palette) with mirroring from SPRITE SHOW's rotation argument, halves the storage and adds a cache; it is the fallback if the sheet does not fit.

Water and wind

Water is not tiles. Four waterlines by x-range (starting at columns 0, &54, &74 and &A0, at rows &CE, &DF, &C1, &C1) with desired levels that make Triax's lab flood and drain. On the BBC a raster interrupt turns colour 0 cyan for one scanline and blue beneath. For us it is two BOX fills into the background before the objects — black above, blue below, a cyan line at the surface — and the tiles' transparent black lets it show through exactly where the original showed it. Wind is particles and an acceleration on objects in windy squares; both come straight from the tables.

Particles: pixels, as in Thrust

Thirty-two slots, eleven types, each new particle seeded from its type's row of lifetime, speed, colour, flags and randomness, optionally inheriting the parent object's velocity or acceleration. Double-height ones are two pixels; the "plotted on foreground" flag means drawn after the tiles. PIXEL and a two-pixel BOX into the framebuffer, as Thrust does.

Arithmetic across the slots

Exile keeps one table per field, sixteen entries each, and so should we, because that is the layout MATH works on. MATH C_ADD, C_SUB, C_MUL and C_DIV combine two integer arrays element by element, MATH ADD and SCALE apply a constant, MATH SHIFT divides or multiplies by powers of two, MATH SET clears, and C_AND, C_OR and C_XOR work on flag arrays; all of them also accept structure member arrays with a stride, so an object table declared as a structure array is not ruled out, only slower. What they cannot do is anything conditional per element: the ±64 velocity cap, the inertia on the one object whose turn it is, support and collision. So the integration step is seven MATH calls, including the cap through MATH CLAMP a(), lo, hi, b(), which was added to the firmware for this (14 µs for sixteen elements, integer or float, structure members included), and the particles are four calls and a respawn loop. Measured, integration falls from 2.8 ms in a loop to 0.23 ms, and the particles from 2.8 to 0.7. Only the one inertia object per tick and the respawns stay as per-element code.

The object update: in C, checked against the game D8

Phase 3 measured a faithful player kernel at 9.8 ms a tick in BASIC and 0.1 ms as a CSUB, the same transcription in C and the same 2,384-tick check. The physics and the behaviours of all sixteen slots go into that CSUB, called once a tick with the state array, the packed tables and the 64 KB world array (the arithmetic is 8-bit and the tables are bytes, so the translation is mechanical). BASIC keeps what it is good at: reading keys, drawing the frame with TILEMAP and BLIT, stepping the sound envelopes, and the game's events. Two build rules learned: no jump tables (a switch wants a libgcc helper the blob cannot have, so armcfgen.py now compiles with -fno-jump-tables), and no writable statics, so the state lives in the argument array.

Creatures: tables first, behaviours second

The port's largest single body of work. The walking kernel and its seven walking types, the mood machine, the stimulus table, target selection and directness, line of sight and the remembered target all transcribe as data plus one shared routine; that is most of what makes the creatures feel alive. Then the per-type behaviours, about forty routines of ten to eighty instructions each, become SUBs dispatched with SELECT CASE on type. OPTION CACHE SUB and OPTION TRACECACHE, which paid for themselves in Elite, apply here.

Sound D2

The BBC's ENVELOPE has three pitch sections and a four-phase amplitude curve; Exile's envelopes are arbitrary lists of stages with nested loops, stepping every vsync, on frequency and volume independently. So the game does what the original interrupt did: it steps the envelope tables itself, two vsync steps a frame, and issues each step as PLAY BBC SOUND &H10 + ch, -loudness, pitch, 1, a flushed 50 ms note that replaces the last one and falls silent by itself if the program stops writing. The frequency value goes through the original's four-range table to a chip period, then to the nearest BBC pitch unit; that rounding is at most half a unit, 12 cents, and on the teleport sound, the worst case with its 120-cycle warble, it could not be heard against an exact-frequency reference. The 208-byte envelope table and the 49 four-byte sound blocks transcribe as data, as Thrust's did. The seven speech samples are extracted to WAV by Tom Seddon's script and go through PLAY WAV.

Input, saving, determinism

Thirty-nine actions on KEYDOWN; Exile's worst case is thrust, aim, fire and the booster together, inside the six-key limit. The BBC save is a dump of the state, so ours is the object lists, the tertiary state bytes, the waterlines, the player's kit and the teleport memories to a file. Keep the original's four-byte random number generator so that a bug seen on the board can be reproduced on the PC.

Program size

Thrust's 4,937 instructions became 1,245 lines of BASIC plus generated data. Exile's 8,528 are branchier and table-heavier, so expect three to four thousand lines of code with almost no DATA, because the map, the tiles and the sprites live in files and flash. Loaded with crunching that is within the 144 KB, but it will be the largest program yet on the board, and CHAIN with VAR SAVE is the fallback if it is not.

Reference

The physics kernel, in numbers

Everything in this table is from the level7 listing or the bbcexile physics chapter and is what the BASIC must reproduce. It is the equivalent of Thrust's table of divisors: get these right and the game feels like Exile.

QuantityValueNotes
Positionsquare + 8-bit fraction per axis16 fractions per pixel across, 8 down
Velocitysigned 8-bit fractions per tick, limited to ±&40a quarter square a tick; objects at the cap stop accelerating
Gravity+1 to vertical velocity per tickfolded in as the carry from CPX #2
Inertiaeach velocity moves 1 toward zero every 16 tickson a per-object counter: frame_counter + slot
Water dragboth velocities × ⅞ every 4 ticks
Buoyancyup to 4 iterations, each a quarter of the height submerged; each surviving one lifts vertical velocity by 1 or 2 by weightshorter and lighter objects float more
Weight3 bits of the type flags; 7 is staticplayer 3, changed by what is held
Jetpack±1 acceleration per tick per direction key; doubled with the booster1 energy per 2 or 8 ticks
Jumpupward velocity (10 − weight) × 2, or (16 − weight) × 2 with the boosteronly within 5 ticks of standing on walkable ground
Walkingmax slope &32 (70°) for the player, &20 (45°) slimes and robots, &80 for frogmen, imps and worms; acceleration 6, 8, 16, 3, 4, 8 by typeplus turn and jump probabilities
Tile collision8-byte obstruction profile per tile type, one height per two-pixel columnthe tables at &0100
Bouncereflect, softened by ⅛ of the impact angle; speed capped at &20, less 2, × ⅞
Impact damage(angle from perpendicular ÷ 4) + speed − &40, halvedonly when positive
Surface windcentred on the crash site, none within &1E of it, doubling at &32 and &3C; none below the surfaceweight-scaled
Cavern windvariable-wind squares rotate through a circle every 64 tickstwo western caverns blow constantly
Angles256 to the circle, clockwise from rightapproximate vector routines; copy the tables
Demotionoff screen by 4 squares if fast or unsupported, 12 otherwise; tertiary return at 1type flags &20 / &50 / &60 / &70
Energyplayer &7F; ranges &07 to &FF by type; four explosion types at zeronear death the player is teleported
Scope

What we deliberately do not port

Original techniqueWhy it existedWhat we do
Generating the world on the fly65,536 squares would not fit in 32 KBGenerate once on the PC, ship 128 KB, load into TILEMAP
Circular screen buffer and start-address scrollingRedraw only the exposed strip on a 2 MHz CPUTILEMAP DRAW of the viewport every frame
Self-modifying sprite plotter, flips by loop direction, palette through the address fieldNo lookup tables, no RAMPre-rendered variants in flash
Per-pixel priority flag in colours 8–15Objects behind scenery without a depth sortDraw tiles after objects; measured to be equivalent for 98.4% of pixels
Raster interrupt for waterWater without a water tileTwo box fills behind transparent tiles
2-pixel and 8-scanline scroll stepsByte and character-row granularity of the bufferPixel scrolling
Novella word check and the demo mode that hangsCopy protectionNothing
Disc save through the supervisorWhere a save could goA file on the SD card
Plan

Phases

0Spikes

Four questions that change the design

Two days. The census above is the first of them, already done.

  • S1 — the tileset in flash. Done 399 drawn variants, numbered most common first so that slot 1 (280 tiles) covers 99.7% of the drawn squares and the slot-2 map has 125 cells in the whole planet. Both tilesets and both maps load on the PC3 (the two 256 × 256 maps in 325 ms), Bas/exile/worldview.bas scrolls the planet at about 2 ms a frame at the surface, and screenshots at the ship and at Triax's lab, where the slot-2 tiles are, match the Python reference renders pixel for pixel. Two flash slots behind two tilemaps had not been tried before; they work.
  • S2 — the interpreter budget. Measured, 88% Testfiles/ExilePhysicsBench.bas runs sixteen objects through the kernel's shape (integrate, eight-column obstruction profiles against a loaded 256 × 256 map, support and bounce, walker, flyer and bullet behaviours, gravity, the cap and the staggered inertia), thirty-two particles and the frame's drawing. A full tick is 35.6 ms of 40 at 315 MHz (the clock that gives 640 × 480 at 75 Hz, three refreshes to a tick) and 30.0 ms at 378 MHz (60 Hz, two and two-fifths refreshes to a tick): integrate 2.8 / 2.3, tile collision 15.7 / 13.3, behaviours 4.6 / 3.9, particles 2.8 / 2.3, draw 11.8 / 9.8. Everything scales with the clock, so the choice between the two is the display cadence, not the budget: at 75 Hz a 25 Hz tick lands on every third refresh exactly, at 60 Hz it alternates between two and three. OPTION TRACECACHE halves the integrate loop and changes nothing else, because the tick is calls and array traffic, not tight arithmetic. Per primitive on this board: an assignment 6 µs, an array read 13 to 23 µs, a TILEMAP(TILE) lookup 12 µs, a sub call 19 µs, a function call 27 µs; a transparent TILEMAP DRAW of the screen 3.0 ms against 0.36 ms opaque; a 32 × 32 BLIT FLASH 107 µs. Three reductions follow from those numbers and take the estimate to about 20 ms: test each object's box once with TILEMAP(COLLISION) against FULL and SLOPE attributes and run the column scan only on slope tiles (the scan is 60 statements an object today); draw the second slot's map only where it has tiles, since an empty region costs 0.02 ms; and do the integration and particle arithmetic across all slots with MATH C_ADD and the new MATH CLAMP, which measured 0.23 ms against 2.8 and 0.7 against 2.8. The reserve, if the real behaviours come in heavier than the stand-ins, is the collision kernel as a CSUB, which would take its 15.7 ms to well under one.
  • S3 — the geometry. Done Measured at both widths on the board in the worst region: the tilemap pass 2.43 against 2.03 ms, the whole frame 5.14 against 4.51 ms. The letterbox was taken (D4), for the creature population more than for the drawing.
  • S4 — the sound primitive. Decide D2. If the primitive is built, play the teleport sound from the original tables and compare against an emulator.
1Data

The game's own code, run on a 6502 in Python

World and tiles done Mirrors thrust_tools/: every generator draws what it extracts.

The plan said a Python port of get_tile. What was built is better: the level7 listing prints the machine-code bytes of every routine, so exile6502.py loads it back into a 64 KB image and runs the game's own landscape generator, tertiary lookup and palette code on a 250-line 6502 interpreter, exact by construction, self-modifying code included. gen_world.py sweeps all 65,536 squares that way in 4.5 seconds (8.7 million instructions) and agrees with the C# census on every square; it writes the whole-planet map, the two per-slot maps, the type bytes the physics needs and the variant table. gen_tiles.py renders the 399 variants from the sprite sheet at &53EC through their palettes into the two slot BMPs, and its landing-site render is identical to the census generator's. gen_tables.py cuts 51 tables out of the listing under their own labels, obstruction profiles to sound envelopes, into a BASIC DATA fragment, and dumps all 255 tables it finds to JSON. gen_objects.py builds the slot-3 object sheet. One listing typo was found on the way, a line at &4B91 that belongs at &4B9A, and the loader corrects it. gen_objects.py renders the object sheet and its coordinate table. gen_tables.py pulls the particle types, walking types, type flags, obstruction profiles, angle tables and the object type and sprite tables out of the listing. gen_sound.py transcribes the 49 sound blocks and the envelope table, and extracts the samples.

The check worth having: the census already says how many squares every tile variant should cover. If the Python generator's totals do not match the C# ones to the square, something has moved.

2World

A planet you can fly around with the cursor keys

Done No physics. Tiles, water, the camera, and the load path from D1.

Bas/exile/worldview.bas: both tilesets into flash, both maps loaded, the four waterlines as blue fills under transparent tiles, arrow keys and jump keys, numbered screenshots. Verified pixel-exact against the Python renders at two places. The 256-wide view and the panel are in it. Still to do here: a door flipped with TILEMAP SET.

3The player

The kernel, with one object in it Done

The soul of the game, as phase 3 was for Thrust.

The physics update in the original's order with its constants, tile collision through the obstruction profiles, support and wedging, walking on slopes, jumping, the jetpack and booster, water, wind, and the energy cost of all of it. Checked more strictly than Thrust was: the game itself is the reference. exilegame.py runs the original's main loop one tick at a time on the 6502 interpreter with the keys driven from Python, and gen_traces.py records the player's slot after every tick of eighteen scenarios (standing, falling, walking both ways, jumping, the jetpack with and without the booster, a long flight, lying down, turning, dropping into the western caves' water, a cave ceiling and wall, an earth slope and a stone one): 2,384 ticks. exilephys.py is update_object for the player transcribed at the 8-bit level, carries included, and matches every one of them. Bas/exile/exilephys.bas is that Python translated routine for routine into MMBasic, and on the PC3 (gen_phystest.py, run_phystest.py) it also matches every one of the 2,384 ticks.

What the checking turned up: the second sound of a scream in the level7 listing is printed as JSR &14fa, the middle of the save-game encryption, which ends by wiping memory; the game and the comment say &13fa, and the loader now corrects it. The jetpack drains one unit or two each time, depending on a carry the particle code leaves behind (two is the common case and what the port will do). Climbing a slope steeper than 63° takes its direction from a zero-page temporary that the last walking creature left, an original quirk the port keeps by sharing the variable. The water level breathes: its fraction rises two a tick. And the player's own damage on hitting a wall depends, by one, on the carry the collision code happens to leave.

The cost, and what followed from it. The player's kernel alone, faithful, takes 9.8 ms a tick on the PC3 in BASIC, a quarter of the tick for one object, and the statement count, not the arithmetic, is what costs (S2's 35.6 ms was measured on a kernel of the right shape but a fraction of the branches). OPTION TRACECACHE does not help: with everything cached but one sub the kernel is slower (11.5 ms), because it is subs with locals called once a tick, and with that sub (CollTiles) cached the result is wrong, which is a firmware finding in its own right (bisect_cache.py). So the object update went to C: csub/exilephys.c is the same transcription again, built into a CSUB by armcfgen.py (10,988 bytes of code), called once a tick with the state array, the world and the packed tables, and checked by the same feed files: all 2,384 ticks match, at 0.1 ms a tick, a hundred times the BASIC. Decision D8 is taken: physics and behaviours in the CSUB, BASIC for input, drawing, sound and events.

4Things

Objects that are not alive In progress

The three lists, and everything you can carry.

Primary, secondary and tertiary lists with promotion and demotion at the right distances; picking up, holding, pocketing, dropping and throwing with the weight rules; the six weapons and energy transfer; keys, doors, switches, transporters and their destination table; boulders, grenades, flasks, mushrooms; the transporter beam; teleport memory and the near-death teleport.

Done so far. The CSUB is now the whole-scene kernel (csub/exile.c, ExileTick): update_objects for all sixteen slots, with the collision pass between objects and its velocity transfer, held objects (pick up, carry, drop), removal by distance and demotion to the secondary list, the teleport countdown, the per-type dispatch, and the first behaviours: the inert objects, the giant block and the collectables. The oracle records whole scenes (gen_traces2.py: every slot every tick, the water level, the screen position, and every random number the code drew keyed by the address that drew it, so the kernel asks for the ones it models and skips the rest). Twenty-five scenes match the game tick for tick and slot for slot, 3,336 ticks: the eighteen player scenes and seven with boulders, a piano and a key (resting, pushed, dropped on the player, rolling down a slope, a heap, picked up and carried). 0.14 ms a tick with four objects. Not yet: objects created when their tile is drawn (the ship's turret appears the moment its tile scrolls into view), promotion from the secondary list, the events, and the creatures.

5Creatures

One kernel, forty behaviours

The long phase. Do it in the order the player meets them.

The walking kernel and its tables, moods and stimuli, targets and line of sight; then birds and nests, imps and their gifts, frogmen, slimes, robots and turrets, wasps and piranhas, the hovering balls, Triax and the clawed robots. Each behaviour is verified against the listing's own notes, which for most of them say in a sentence what the creature is trying to do.

6Sound

Forty-nine sounds, seven samples

Short: the stepper exists in ExileTeleportSound.bas; the other 48 blocks are data.

7Game

Beginning, middle and end

Structure around the simulation.

The title and the landing, the events system, Triax's lab with its maggot machine and doors and flooding, the earthquake, the destinator, and saving and restoring. Then the enhanced version's extras: the speech, and the viewpoint-object debug feature, which is worth keeping as a development tool.

Risks

What could go wrong

RiskLikelihoodMitigation
The tileset does not fitClosedCounted: 401 tiles, two slots, one to spare.
The priority scheme needs a second tilesetClosedCounted: 6 variants have any non-priority pixel. Draw tiles last.
The interpreter cannot tick sixteen creatures in 40 msMeasuredS2: 35.6 ms of 40 as first written, with collision at 15.7 ms and the draw at 11.8, for a kernel of the right shape. The faithful kernel costs 9.8 ms a tick for the player alone in BASIC and 0.1 ms as the CSUB it now is (D8), checked against the same 2,384 ticks. Sixteen objects are then under 2 ms; the draw and the particles stay where they were measured.
Loading the map into TILEMAPClosedTILEMAP LOAD built and tested on the board; 65,536 cells in 476 ms.
The sound does not sound like ExileClosedThe engine's own envelopes run in BASIC and drive PLAY BBC SOUND step by step; the teleport sound passed a listening test against an exact reference.
The generator port is subtly wrongLow65,536-square comparison against the C# transcription, which the census has already exercised end to end.
Program memoryMediumThree to four thousand lines, crunched on load. CHAIN the title and save screens out if needed.
A behaviour that depends on the 2-pixel scroll or the CRTC (the earthquake shudder, the teleport shrink)LowBoth are cosmetic and have obvious framebuffer equivalents.
Tooling

Data pipeline

Bas/exile_tools/
    census/              this review's harness: Program.cs, README.md, the two images   (done)
    exile-disassembly.txt   the level7 listing - fetched, NOT vendored
    exile6502.py         loads the listing into a 64 KB image; a 6502 that runs its routines   (done)
    gen_world.py         the planet from the game's own code; maps, type bytes, variants; --check  (done)
    gen_tiles.py         399 variants -> two slot BMPs, contact sheets, previews                (done)
    render_window.py     a 320 x 240 reference view for checking the board's screenshots      (done)
    gen_tables.py        51 tables -> tables.bas DATA under labels; every table -> tables.json  (done)
    gen_objects.py       object sheet for slot 3: 213 sprite-palette pairs x 4 orientations   (done)
    test_objects.py      objview.bas + a reference picture; 0 pixels differ on the board       (done)
    ../exile/worldview.bas   phase 2: the planet on the PC3, scrolling, screenshots            (done)
    gen_objects.py       object sheet BMP + coordinate table; --png contact sheet
    gen_tables.py        particles, walking types, type flags, obstruction profiles, angles, sounds
    gen_sound.py         49 sound blocks, the envelope table, samples -> WAV
    out/                 generated .bas fragments, BMPs, verification images
    exile_code.bas       the game without generated data          (phase 2 on)
    build.py             glue, writing ../exile.bas                 (phase 2 on)

The listing and the disc image stay out of the tree for the same reason Thrust's did. The C# generator is MIT-licensed and could be vendored, but the census README shows how to fetch and build it in three commands, which is enough.

References

Sources