Yes—and I would organize it into something that lets technical-community readers immediately understand what makes this port impressive, rather than merely listing its features.

Porting Tyrian to the GBA: Not Just a Game Port, but a Complete Performance-Engineering Effort

TyrianGbaPoc originally set out to answer a very direct question:

Can the DOS-era Tyrian be brought to the Game Boy Advance while preserving as much of the original game logic, data, and behavior as possible?

But if you follow the project all the way through, you will find that it eventually went far beyond what most people imagine when they hear the word “port.”

It was not merely a matter of replacing APIs, shrinking the display, and reconnecting the audio. Instead, the project treated the original Tyrian 2.1 data and OpenTyrian's gameplay behavior as the specification, then reconsidered the following question under the GBA's extremely limited CPU, VRAM, OAM, palette, and timing budgets:

How can a game whose original resource requirements greatly exceed the GBA's capabilities still preserve correct behavior and run reliably?

As a result, the project naturally came to involve:

Algorithms, data structures, memory layout, caching strategies, resource scheduling, real-time systems, workload shedding, color quantization, and low-level ARM optimization.

In other words, this may look like a GBA game port on the surface, but in practice it is closer to a complete performance-engineering case study.


Core Principle: First Avoid the Work, Then Make the Remaining Work Fast

If I had to choose the single most transferable principle for other projects, it would be this:

Do not begin by asking, “How can this code run faster?” First ask, “Does this work need to happen at all?”

The project's optimization process broadly followed this order:

Eliminate unnecessary work
        ↓
Reduce the working set
        ↓
Choose a more appropriate data structure
        ↓
Improve locality and caching
        ↓
Schedule scarce resources
        ↓
Batch work and fuse loops
        ↓
Optimize the real hot kernels
        ↓
Only then consider ARM and IWRAM

This order matters.

On severely constrained hardware, the biggest gains usually do not come from:

Making each of 100 operations 10% faster.

They come from:

Preventing 80 of those operations from happening at all.


1. Active Directories and Pools: Do Not Scan Every Object on Every Tick

OpenTyrian's enemy pool can be understood in simplified form as:

4 pools × 25 slots per pool
= 100 enemy slots

The most straightforward implementation would be:

Every tick:
for 0..99
    if active
        update

On a PC, this might not matter at all.

On the GBA, however, even an empty slot still incurs:

The GBA version therefore maintains additional compact active directories, such as 32-bit bitmasks, that identify which enemy slots are active or collidable.

There is one crucial design detail:

The bitmask is only an acceleration index; it is not the authoritative state.

The original enemy pool remains the true source of game state.

Consequently, optimization does not need to change:

None of these need to change merely because of the optimization.

This is an extremely practical structural pattern:

authoritative structure
+
auxiliary acceleration structure

You can think of it this way:

The original data structure is responsible for correctness; the additional directory is responsible for speed.


2. Not Every Pool Should Use a Bitmask

An important point here is that the project did not blindly convert every pool to bit scanning merely because bitmasks were effective in one place.

The effect pool contains roughly 200 slots.

It was also tested with:

active mask
→ bit-scan active effects only

But in a heavy scene, more than 100 effects may be active, making the data quite dense.

At that point:

bit scanning
+ index extraction
+ mask handling

may actually be slower than simply:

for 0..199
    if inactive continue

using the straightforward linear scan.

The final approach was therefore:

This contains an important algorithmic lesson:

A sparse representation is not inherently faster. What really matters is data density.

This is the so-called:

density crossover

Player shots use an even more direct hybrid approach:

Low active count
→ bitmask traversal

High active count
→ linear scan

This is more mature than assuming that “bitmasks are always superior,” because the real question is:

Which method has the lowest cost at the current density?


3. Cache Hints: Use a Very Cheap Prediction to Accelerate a Correct Cache

The projectile sprite cache already had a high hit rate.

Even on a hit, however, it could still be necessary to scan several cache entries before finding the correct slot.

Rather than replacing the entire cache, the project placed a very small layer in front of it:

direct-mapped hint table

The flow is approximately:

key
 ↓
hash
 ↓
hint slot
 ↓
validate the complete key
 ↓
match → use it directly

no match
 ↓
fall back to the original complete cache lookup

The most elegant aspect of this design is:

The hint is not responsible for correctness.

It does not manage:

Therefore, even when there is a:

hash collision
stale hint

the worst possible result is:

hint miss
→ fallback

It cannot produce a false cache hit.

This concept is useful far beyond this particular system:

cheap predictor + validation + authoritative fallback

In other words, predict first, but always validate.

A wrong prediction makes the operation slower, not incorrect.


4. The Boss Sprite Problem Ultimately Was Not Scheduling, but Data Representation

Boss scenes once suffered from disappearing components.

The obvious first suspicion was:

The GBA has only 128 OAM entries. Are we running out of OAM?

Telemetry showed that OAM had not yet been exhausted.

The resource actually being exhausted was OBJ VRAM.

A 32×32 8bpp sprite requires:

32 × 32 × 1 byte
= 1024 bytes

If a boss uses many distinct frames simultaneously, the cache fills very quickly.

No amount of work on LRU or replacement policy can change the fact that:

The physical capacity is insufficient.

The solution was therefore not “evict more intelligently,” but to change the representation itself:

32×32 8bpp
= 1024 bytes

       ↓

32×32 4bpp
= 512 bytes

The same amount of VRAM can hold nearly twice as many sprites.

This is a classic systems-optimization principle:

When cache capacity is the fundamental problem, first ask whether the data itself can be made smaller.

Do not keep circling around eviction policy alone.


5. Moving to 4bpp Leads Directly to Palette Training

Once the representation changes to 4bpp, another class of problem immediately appears:

A palette bank has only 16 entries, and index 0 usually has to be reserved for transparency.

In practice, this leaves only about:

15 usable colors

Tyrian's original artwork, however, uses 256-color indexed graphics.

The new problem is therefore:

How can the information in the original 256 colors be compressed into 15 colors without destroying the image?

This led the project to introduce palette training and color quantization.

The basic idea is to take:

color frequency
+
distance from already-selected colors

into account together.

Conceptually, the score resembles:

score =
nearest color distance
×
frequency weight

This provides two benefits:

For example, a boss might contain:

a large amount of gray metal
+
a small number of red eyes
+
a small number of blue lasers

If the system considers only the histogram, the final 15 colors may contain many similar grays.

With color distance included, colors such as red and blue that are far from the current palette have a better chance of being preserved.

This is particularly appropriate for pixel art.


6. After Training the Palette, Build Lookup Tables

Training the palette is not enough by itself.

If every pixel must repeatedly calculate:

Which entry in the 15-color palette is closest to this original color?

the runtime cost remains high.

The system therefore first builds:

palette_map[256]
palette_error[256]

For every source color, these tables identify:

The actual sprite conversion then becomes:

source color index
↓
table lookup
↓
4-bit palette index

This is a classic technique:

Move expensive calculations into precomputation so the hot path only performs table lookups.


7. Palette Pretraining: Do Not Wait Until the Boss Appears

Another practical problem appeared later.

Not all boss components necessarily appear at the same time.

For example:

Only dark components appear at first
↓
The first trained palette is biased toward black and gray

Only later do the following appear:

green components
red components
bright attack effects

At that point, the original palette is no longer ideal.

The system could retrain during combat, but that would sharply increase CPU pressure.

The more elegant final solution was:

Read the level data
 ↓
Identify the boss cohort
 ↓
Locate the related enemy definitions
 ↓
Decode their animation frames
 ↓
Collect the complete histogram
 ↓
Train the palette in advance

The palette is ready before the boss actually appears.

This principle is worth remembering in other porting projects:

Do not leave predictable expensive work in a latency-sensitive gameplay phase.

Move it into:

whenever possible.


8. When OAM Is Insufficient, the Problem Becomes Scheduling

The GBA provides exactly:

128 OAM entries

Some heavy Tyrian scenes can demand substantially more than 128 sprites.

At that point, it no longer makes sense to ask:

Can the renderer be made a little faster?

The problem is not speed.

The problem is:

The hardware has only 128 slots.

The correct way to view the problem is therefore:

Resource scheduling

The system must decide:

Who must be displayed?
Who can be deferred?
Who has priority?
Who can be sacrificed?
Who should take turns receiving the resource?

A conceptual priority order might be:

High priority
player
bosses
important enemies

Medium priority
projectiles

Low priority
explosions
shadows
cosmetic effects

If high-priority objects do not consume all OAM:

remaining OAM
→ cosmetic effects

If high-priority demand alone already exceeds capacity:

cosmetic effects
→ may be dropped

This is no longer simply a rendering technique. It is a standard finite-resource scheduling problem.


9. Priority Is Not Enough; Fairness Also Matters

If resources are always assigned in a fixed order:

early effects are always displayed
later effects always disappear

the visual result is poor.

The system therefore also needs round-robin scheduling or rotating cursors, so low-priority objects still have opportunities to be displayed in turn.

This produces a combination of:

priority
+
fairness
+
work-conserving allocation

This concept is not limited to GBA OAM.

The same principle applies when working with:

All of these are the same class of problem.

The core principle is:

When demand can exceed hardware capacity, you need a scheduler, not merely an allocator.


10. Candidate Snapshots: Do Not Repeatedly Scan the Same Data

The OAM scheduler also contains several practical optimizations.

The original process might have been:

scan once to calculate demand
scan once for cache admission
scan once to render

It was changed to:

collect candidates once

Precompute for each candidate:
screen position
visibility
OAM cost
draw stage
priority

The scheduler, cache admission, and renderer then share this same materialized data.

This is:

materialize the hot working set once


11. Loop Fusion: If One Pass Is Enough, Do Not Make Two

Loop fusion is another technique in the same family.

The original flow might be:

select an enemy
↓
store the selection
↓
scan again to prime the cache
↓
render

It can instead become:

selection
+
cache admission
+
cache acquisition
+
tile recording

Everything is completed in the same traversal.

This optimization may not look as glamorous as assembly language, but it is extremely useful on constrained hardware.

It simultaneously reduces:


12. Adaptive Work Shedding: Separate Simulation from Presentation

This is one of the most important architectural ideas in the entire port.

The game's:

logic
enemy movement
collision
events
RNG
boss progression
audio timing

belong to the authoritative simulation.

These cannot be skipped arbitrarily simply because the renderer is overloaded.

By contrast:

sprite construction
OAM updates
tile uploads
VRAM presentation
background rendering

are presentation work.

When the system is genuinely overloaded, it therefore should not:

skip a gameplay tick

It should instead:

continue running gameplay ticks

but omit construction of one presentation frame

For example:

Simulation:
N
N+1
N+2
N+3

Presentation:
N
N+2
N+3

The player sees one fewer intermediate image, but:

This is essentially:

fixed-timestep simulation + adaptive presentation cadence

It is already very close to real-time systems design.


13. Dropping a Frame Does Not Mean Drawing Half a Frame

There is another crucial detail:

If the next scene is not expected to finish in time:

do not commit half of it

Instead:

retain the previous complete scene

The worst outcome is not displaying one fewer image.

The worst outcome is producing:

half of the new OAM state
+
half of the old VRAM state
+
partially updated caches

an inconsistent state of this kind.

The strategy is therefore closer to:

Commit the complete state, or do not commit at all.

This is very similar to the reasoning behind transactions and double buffering.


14. Collision: The Hard Part Is Not the Formula, but Pool Mutation

Collision detection looks simple on the surface:

for every player shot
    for every enemy
        collision test

But with:

81 shots × 100 enemies

the candidate count grows quickly.

An active directory can first eliminate inactive enemies.

The real difficulty, however, is:

Collision itself can mutate the enemy pool.

For example:

a shot hits an enemy
 ↓
the enemy dies
 ↓
a new enemy is spawned
 ↓
the slot is reused

It is therefore unsafe to simply:

snapshot the active mask at the start of the tick
↓
use that snapshot for all collision processing

The underlying data may already have changed later in the traversal.

Optimization must therefore preserve:

This class of problem is actually harder than simply applying SIMD or bitmasks.


15. Packed Collision: Make the Miss Path Fast and the Hit Path Conservative

The later ARM collision kernel uses an elegant technique.

For a given active mask, it keeps:

remaining_bits

in a register.

If every candidate continues to miss:

candidate A misses
candidate B misses
candidate C misses

there is no need to reload the authoritative mask repeatedly.

But when a real hit occurs:

hit
→ gameplay helper
→ may mutate the enemy pool

the cached mask is discarded and the authoritative state is reloaded.

In practice, this means:

common miss path
→ aggressively cached

rare mutation path
→ conservative reload

This is an elegant performance strategy:

Optimistic fast path + correctness recovery point

The common path stays fast.

The implementation returns to a conservative mode only where state may truly change.


16. ARM Assembly Is the Final Cut, Not the First

The project does contain ARM and IWRAM optimizations.

What is more important, however, is that it did not treat:

“Rewrite it in ARM”

as the first reaction.

For the starfield, the real initial problem was not the programming language.

The original flow might repeatedly perform:

position / 320
position % 320

pixel 1 → tile lookup
pixel 2 → tile lookup
pixel 3 → tile lookup
...

It was first changed to:

one divmod operation
one tile allocation
batch several pixels within the same tile

This step made the algorithm reasonable before any assembly was introduced.

Only then was the remaining hot kernel rewritten in ARM.

The real source of the improvement was therefore:

algorithmic batching + elimination of repeated work

ARM simply extracted the remaining performance.


17. Sometimes ARM Is Even Slower Than C

This is particularly educational.

Not every instance of:

is guaranteed to be faster.

Some very small helpers rewritten in ARM may incur:

and consequently lose to C.

Even C code that appears to contain branches may already be optimized by the compiler using conditional execution.

The correct approach is therefore:

Inspect the machine code, inspect the profile, and inspect the benchmark.

Do not judge from source syntax alone.


What Technical Substance Does the Entire Port Contain?

For engineers who want the quickest overview, it can be condensed into this table:

Problem Technique
Large numbers of empty pool slots Active / Free Bit Directory
Sparse / dense workloads Density-dependent traversal
Many cache hits, but lookup remains expensive Validated Direct-Mapped Hint
Insufficient sprite-cache capacity 8bpp → 4bpp representation change
Insufficient color capacity in 4bpp Palette Training / Quantization
Training during gameplay is too expensive Boss cohort preload / pretraining
OAM demand exceeds 128 Priority + Fairness Scheduler
The same candidates are repeatedly scanned Candidate Snapshot
Multiple traversals perform similar work Loop Fusion
Occasional frame overload Deadline-aware frame dropping
Sustained overload Adaptive Work Shedding
Gameplay ticks cannot be skipped Fixed-timestep Simulation
The collision pool can mutate Mutation-safe Active Directory
Repeated address calculations on the hot path Batching
The remaining true CPU bottlenecks Selective ARM / IWRAM
Optimization can break original behavior Differential / Regression Testing

The Most Transferable Ways of Thinking for Other Porting Projects

If you are porting software to another platform, the following questions are more valuable than any single technique.

Do not begin by asking:

How can this loop be made faster?

First ask:

Why do these objects need to be visited?


Do not begin by asking:

How else can the cache policy be tuned?

First ask:

Can the data representation itself be made smaller?


Do not begin by asking:

How can every sprite be displayed?

First ask:

When hardware capacity is insufficient, which objects must have priority?


Do not begin by asking:

How can this expensive operation be made faster during gameplay?

First ask:

Can it be calculated during loading or preprocessing instead?


Do not begin by asking:

Can this be made branchless?

First ask:

What code is the compiler actually generating now?


Do not begin by asking:

Should this be written in assembly?

First ask:

Does profiling prove that it is worthwhile?


In the End, It Can Be Reduced to One Sentence

If the engineering value of TyrianGbaPoc had to be summarized in one sentence, it would be:

Optimize the architecture before optimizing the instructions.

What makes this project interesting is not merely:

“Tyrian can actually run on the GBA.”

It demonstrates how, when a system originally designed for a more generous platform must be fitted into severely constrained hardware, you can bring together:

Algorithms, data structures, sparse traversal, caches, palette quantization, memory representation, resource scheduling, real-time work shedding, profiling, regression testing, and ARM hot paths.

and combine them into one system.

It is therefore more precise to say that this is not merely a game-porting project.

It is a complete case study in:

How systematic performance engineering can fit a relatively large existing software system into a platform with extremely limited CPU time, memory, graphics resources, and timing budget, while preserving as much of the original behavior as possible.

This is also why these techniques are useful beyond the GBA.

Whenever you face:

many of the ideas in this project can be carried directly into other work.