Reverse engineering an Amiga game is an exercise in peeling back layers. For my ongoing project documenting Wicked, my recent goal was to find the main gameplay loop and map out the execution order of its core systems (player movement, spore growth, guardian AI, rendering).
Here is a technical breakdown of exactly how Wicked manages its execution state, and how I tracked down the true heartbeat of the game engine — a 25 FPS gameplay loop hidden inside a 50 Hz interrupt handler.
The Macro Loop: A Holding Pattern
First passes of the disassembly pointed towards a routine at $2884C. This block of code clearly served as a state machine dispatcher. It evaluated a core variable I originally named GameControlMode (located at $5B2), checking against values like 0 (Gameplay), 5 (Hi-Score Screen), 6 (Grave Screen), and so on.
MainLoop:; Call state management subroutine JSR LAB_StateActive_2D90C ;28892; Test if game is over TST.W GameOver ;28898; If not, branch to check game mode BEQ.W LAB_CheckGameMode_288B2 ;2889e
The logic flow seemed obvious: jump into LAB_StateActive_2D90C, execute the frame logic, check if the game is over, and then bounce around the state machine based on the current mode.
But when I looked more closely at LAB_StateActive_2D90C, it didn’t contain player logic, collision detection, or pathfinding. Instead, it was an orchestration routine for non-gameplay visual effects.
If GameControlMode was set to 0 (Active Gameplay), LAB_StateActive_2D90C would immediately execute a return (RTS). It did absolutely nothing during the main game. This confirmed that the MainLoop from $28892 wasn’t driving the gameplay; it was effectively a macro wrapper, managing high-level screen transitions and waiting for the user to press the joystick fire button.
So, what was LAB_StateActive_2D90C actually doing when GameControlMode wasn’t 0?
TBL_33E28 and Automated Animation Sequences
When GameControlMode dictates an active non-gameplay screen (like the Hi-Score screen), LAB_StateActive_2D90C uses a variable previously assumed to be the global GameState ($446). The routine takes this value, multiplies it by 4 to create a longword offset, and uses it to pull a pointer from a jump table located at TBL_33E28.
; Load game state (1-based index) MOVE.W GameState_446,D0; Double index twice for longword offset ADD.W D0,D0 ADD.W D0,D0; Load rendering table address LEA TBL_33E28,A0; Fetch pointer (using -4 offset) MOVEA.L -4(A0,D0.W),A1; Execute rendering subroutine JSR (A1)
TBL_33E28 contains five function pointers, pointing to specific graphics and Copper list transition routines (SUB_AdjustBufferOffsets_2DB5A, LAB_RenderGraphicsType2_2DBB4, etc.). But what was pushing the values into what I thought was GameState_446?
Just above this logic, another routine iterates over a secondary array at TBL_33E3C:
TBL_GraphicsAnimSequence_33E3C: DC.L $00010003, $00020005, $00010003 DC.W $ffff
This acts as a hardcoded animation script. Treated as a sequence of words, the data reads: 1, 3, 2, 5, 1, 3, terminated by FFFF. The game extracts the next word from this script, writes it into GameState_446, and then uses the jump table to execute the corresponding visual effect. When it hits the FFFF terminator, it loops back to the start.
This system exclusively drives the spinning, day-to-night rotating Sun and Beast graphics! I’ve now renamed the variables to more accurately reflect their purpose: TBL_GraphicsAnimDispatch_33E28 for the jump table, TBL_GraphicsAnimSequence_33E3C for the script, and GraphicAnimFrameState_446 instead of the misleading GameState_446.
An interesting anomaly: State 4 ($0002d944) exists in the dispatch jump table, pointing to a routine that explicitly loads a pointer for the Tarot card graphics overlay (TarotEffectPtr_51C). However, 4 is noticeably absent from the automated 1, 3, 2, 5, 1, 3 sequence. This proves that State 4 is injected manually by the game engine when a Tarot event fires — specifically, 37 frames (about 1.5 seconds) into each Day/Night cycle, where the SUB_TimerEvents routine writes 4 directly into GraphicAnimFrameState_446, bypassing the automated spinning background loop.
Finding the True “Heartbeat”: The Level 3 VBL Interrupt
With the MainLoop doing nothing but drawing menus and advancing screen frames, the actual game engine had to be operating asynchronously. In typical Amiga fashion, the heavy lifting of the active gameplay frame is chained to the hardware itself.
Checking the initialization code ($2802C), the game writes $29218 into the Level 3 Autovector interrupt. On the Amiga, Level 3 handles the Vertical Blanking Interrupt (VBI or VBL) — a hardware signal fired every time the electron gun finishes drawing the screen and returns to the top-left corner. For a PAL Amiga, this guarantees a 50 Hz execution cycle. But as I discovered, that doesn’t mean 50 frames of gameplay per second.
The Three-Tier Dispatcher at $29218
Navigating to $29218, I expected to find the game engine’s pulse. What I actually found was a sophisticated three-tier dispatcher that runs different subsystems at different rates.
Tier 1 — Audio Processing (50 Hz): Every single VBL, the handler acknowledges the interrupt, saves registers, processes the Tarot timer via SUB_HandleTarotTimer_2BF96, and dispatches audio — either SUB_ProcessActiveSoundEffects_39294 or, when an event lock is active, SUB_InitializeAudioAndInput_37A7A. This runs unconditionally at full 50 Hz speed.
Tier 2 — Visual Effects (~50 Hz): After restoring from the audio save, the handler checks several gate flags (LocalControlFlag_594, ControlAckFlag_584, ClearScreenBOOL). If all clear, it saves registers again and runs palette timers (SUB_PaletteTimers_TickAndApply_2A3B8) and the sunset animation (SUB_ManageSunsetAnimation_2940C). For Mode_TitleScreen, it exits here — palette and sunset effects are all the title screen needs from the VBL.
Tier 3 — Gameplay (25 Hz): Here’s where a critical discovery upended my earlier assumption:
ADDQ.W #1, MaskedFrameTick_41A ; $29292 — increment counterMOVE.W FrameSkipMask_4D6, D0 ; $29298 — load mask (= 1)AND.W D0, MaskedFrameTick_41A ; $2929E — mask the counterBNE.W LAB_SetCopperList_29366 ; $292A4 — non-zero → SKIP
A variable I’d originally labelled WinLevel_4D6 is initialized to 1 at $2816E. It’s not a win flag. It’s a frame rate divider mask. When ANDed with the incrementing counter, it forces the gameplay logic to execute on alternating frames only. The result: gameplay runs at 25 Hz (one frame every 40 ms), not 50 Hz as I originally stated. Audio and palette effects continue at the full 50 Hz rate, completely decoupled from the gameplay tick.
There’s a second subtlety here: during gameplay (GameControlMode == 0), the handler does not lower the CPU’s interrupt priority. The status register stays at $2700, meaning the entire gameplay frame executes atomically — no higher-priority interrupts can nest inside it. For other modes, the SR is lowered to $2000, permitting interrupt nesting.
Before entering the 13-step gameplay loop, the dispatcher performs three essential housekeeping tasks. It pushes five game-board working variables onto the stack (GameBoardCellTYPEFlags, GameBoardCellIndex, GameBoardCellCOLUMN, GameBoardCellROW, and CellByteTEMP_50E), clears the per-frame PointsToAdd_48A accumulator to zero, and swaps the double-buffer pointers via SUB_SwapScreenBuffers_2A2E2. These five words are restored from the stack after the gameplay loop completes — meaning the 13 subroutines can freely use these variables as scratch space without corrupting the game state between frames.
Inside the Frame: The 13 Steps of execution
MainGameplayVBL_Update_292F6 is the supervisor of Wicked. Twenty-five times a second, it fires off 13 distinct subroutines (14 calls, as one is called twice) in a rigorously ordered sequence. But before any of them execute, two guard tests gate the entry:
TST.W LevelWonFlag_50A ; $292F6 — level complete?BNE.W LAB_HandleLevelWin_2936CTST.W GrowthAllowedFlag_520 ; $29300 — demo growth active?BNE.W LAB_HandleLevelWin_2936C
If either flag is set, execution diverts to an abbreviated path (detailed below). Otherwise, the engine proceeds through the full 13-step sequence:
flowchart TD
VBL["⚡ Level 3 VBL Interrupt<br/><i>$29218 — fires at 50 Hz</i>"]
ACK["Acknowledge VBL<br/><code>INTREQ ← $0020</code>"]
SRCHECK{"GameControlMode<br/>== 0?"}
SRLOW["Lower IPL<br/><code>SR ← $2000</code><br/><i>Allow nesting</i>"]
SRHIGH["Keep SR = $2700<br/><i>Atomic execution</i>"]
subgraph TIER1["🔊 Tier 1 — Audio (50 Hz)"]
SAVE1["Save D0-D7/A0-A6"]
TAROT["SUB_HandleTarotTimer<br/><code>$2BF96</code>"]
ELOCK{"EventLockFlag<br/>$37A78?"}
AUDIO_NORM["SUB_ProcessActiveSoundEffects<br/><code>$39294</code>"]
AUDIO_INIT["SUB_InitializeAudioAndInput<br/><code>$37A7A</code>"]
RESTORE1["Restore D0-D7/A0-A6"]
end
LOCAL{"LocalControlFlag<br/>$594?"}
LOCALEXIT["Clear flag + RTE<br/><i>Eye of Infinity exit</i>"]
CTRL{"ControlAckFlag<br/>$584?"}
PALETTE_CLR["Clear palette<br/>+ RTE"]
CLEAR{"ClearScreenBOOL<br/>$5E4?"}
CLEAR_EXIT["RTE<br/><i>Screen clearing</i>"]
subgraph TIER2["🎨 Tier 2 — Visual Effects (~50 Hz)"]
SAVE2["Save D0-D7/A0-A6"]
PAL["SUB_PaletteTimers_TickAndApply<br/><code>$2A3B8</code>"]
SUNSET["SUB_ManageSunsetAnimation<br/><code>$2940C</code>"]
end
TITLE{"GameControlMode<br/>== TitleScreen?"}
TITLE_EXIT["Restore regs + RTE<br/><i>Title: palette only</i>"]
subgraph FRAMESKIP["⏱️ Frame Skip Gate"]
TICK["MaskedFrameTick += 1"]
MASK["AND with FrameSkipMask<br/><i>mask = 1 → 25 Hz</i>"]
SKIPQ{"Result<br/>!= 0?"}
COPPER_ONLY["Set Copper list<br/>+ Restore + RTE<br/><i>Skipped frame</i>"]
end
subgraph SETUP["📦 Frame Setup"]
EYEFLAG["Eye mode?<br/>Set LocalControlFlag"]
PUSHVARS["Push 5 game-board words"]
CLEARPTS["PointsToAdd ← 0"]
SWAP["SUB_SwapScreenBuffers<br/><code>$2A2E2</code>"]
end
subgraph GUARD["🛡️ Entry Guards"]
WONCHECK{"LevelWonFlag<br/>$50A?"}
GROWCHECK{"GrowthAllowedFlag<br/>$520?"}
end
subgraph FULLPATH["🎮 Full Gameplay Path (13 Steps)"]
direction TB
S01["① Periodic Event Timer<br/><code>$2B5E0</code>"]
S02["② Hi-Score/Grave Input<br/><code>$2C194</code><br/><i>No-op for gameplay</i>"]
S03["③ CPU Tile Renderer<br/><code>$2A446</code>"]
S04["④ Screen Effects<br/><code>$2CF80</code>"]
S05["⑤ Organic Growth<br/><code>$2CE20</code>"]
S06["⑥ Background Drawing<br/><code>$2C9E0</code>"]
S07["⑦ Player Fire<br/><code>$2AA3A</code>"]
S08["⑧ Guardian & Servant AI<br/><code>$2A546</code>"]
S09["⑨ Star Map Scroll ×2<br/><code>$2A0F6</code>"]
S10["⑩ Player Physics<br/><code>$2B640</code><br/><i>Joystick read + inertia<br/>+ collision opcodes</i>"]
S11["⑪ Timers & Spawns<br/><code>$2AF78</code><br/><i>14 sub-systems</i>"]
S12["⑫ Spore/Crystal Collisions<br/><code>$2BD58</code><br/><i>Tarot effects</i>"]
S13["⑬ Score Management<br/><code>$2D4C8</code>"]
end
subgraph ABBREV["🏁 Abbreviated Path (Level Won)"]
direction TB
A01["① Timers & Spawns<br/><code>$2AF78</code>"]
A02["② CPU Tile Renderer<br/><code>$2A446</code>"]
A03["③ Screen Effects<br/><code>$2CF80</code>"]
A04["④ Organic Growth<br/><code>$2CE20</code>"]
A05["⑤ Background Drawing<br/><code>$2C9E0</code>"]
A06["⑥ Player Physics<br/><code>$2B640</code>"]
end
subgraph EPILOGUE["🔄 Frame Epilogue"]
POPVARS["Pop 5 game-board words"]
RESTORE2["Restore D0-D7/A0-A6"]
FINAL_RTE["RTE<br/><i>Return to MainLoop<br/>for 40ms</i>"]
end
VBL --> ACK --> SRCHECK
SRCHECK -->|"Yes (mode 0)"| SRHIGH --> SAVE1
SRCHECK -->|"No"| SRLOW --> SAVE1
SAVE1 --> TAROT --> ELOCK
ELOCK -->|"== 0"| AUDIO_NORM --> RESTORE1
ELOCK -->|"!= 0"| AUDIO_INIT --> RESTORE1
RESTORE1 --> LOCAL
LOCAL -->|"!= 0"| LOCALEXIT
LOCAL -->|"== 0"| CTRL
CTRL -->|"!= 0"| PALETTE_CLR
CTRL -->|"== 0"| CLEAR
CLEAR -->|"!= 0"| CLEAR_EXIT
CLEAR -->|"== 0"| SAVE2
SAVE2 --> PAL --> SUNSET --> TITLE
TITLE -->|"Yes"| TITLE_EXIT
TITLE -->|"No"| TICK
TICK --> MASK --> SKIPQ
SKIPQ -->|"Yes (skip)"| COPPER_ONLY
SKIPQ -->|"No (run)"| EYEFLAG
EYEFLAG --> PUSHVARS --> CLEARPTS --> SWAP
SWAP --> WONCHECK
WONCHECK -->|"!= 0"| ABBREV
WONCHECK -->|"== 0"| GROWCHECK
GROWCHECK -->|"!= 0"| ABBREV
GROWCHECK -->|"== 0"| FULLPATH
S01 --> S02 --> S03 --> S04 --> S05 --> S06
S06 --> S07 --> S08 --> S09 --> S10
S10 --> S11 --> S12 --> S13 --> POPVARS
A01 --> A02 --> A03 --> A04 --> A05 --> A06 --> POPVARS
POPVARS --> RESTORE2 --> FINAL_RTE
style TIER1 fill:#1a1a2e,stroke:#e94560,color:#eee
style TIER2 fill:#1a1a2e,stroke:#f5a623,color:#eee
style FRAMESKIP fill:#1a1a2e,stroke:#0f3460,color:#eee
style SETUP fill:#1a1a2e,stroke:#16213e,color:#eee
style GUARD fill:#1a1a2e,stroke:#533483,color:#eee
style FULLPATH fill:#0a2e0a,stroke:#4caf50,color:#eee
style ABBREV fill:#2e1a0a,stroke:#ff9800,color:#eee
style EPILOGUE fill:#1a1a2e,stroke:#607d8b,color:#eeeHere is the (current understanding – I could be wrong!!!) step-by-step lifecycle of a single gameplay frame in Wicked:
1. Periodic Event Timer – SUB_CheckExternalTimerOrLevelEnd_2B5E0
This routine operates a configurable periodic tick generator. It increments TimingCounter_59C each gameplay frame and compares it against a threshold in GlobalTickStep_59A. When the counter overflows the threshold, it queues event $13 via the event priority system and resets the counter (subtracting the threshold to preserve any overflow).
Crucially, this routine does not check constellation win conditions or level-end states directly. The actual level-end gating happens in the guard tests above, before the 13 steps begin. And it cannot halt gameplay — it always returns normally via RTS. What it does do is provide a heartbeat for timed game events whose frequency accelerates as the level progresses: when LevelTimerMAIN exceeds 75 (in Step 11), GlobalTickStep_59A is progressively decreased, making this timer fire more frequently as time runs out.
2. Screen-Specific Input – SUB_HandleHiScoreAndGraveScreenInput_2C194
During active gameplay (GameControlMode == 0), this routine does nothing. It checks for modes 5 (Hi-Score) and 6 (Grave Screen), and returns immediately for any other mode.
When active on the Hi-Score screen, it implements an Ouija board-style letter selection system: the player moves a dagger pointer with the joystick (at 2× speed, clamped to screen boundaries 110–287 horizontally, 0–182 vertically), and the routine checks proximity against 21 letter positions in TBL_OuijaBoardTable_33E54 using a 9-pixel hitbox. Pressing fire confirms the selection, converts the letter index to ASCII (index + 45), and stores it in the high score table — up to 5 characters.
On the Grave Screen, it handles two animation states: a sprite moving diagonally across the screen (X+1, Y-1 per frame until X=45), and a slow text-reveal effect drawing tombstone characters at 3-frame intervals.
Joystick input for gameplay is not polled in this step. It’s read directly in Step 10.
3. Tile Rendering – SUB_CopyTileDataToScreenBitplanes_2A446
his is a CPU-driven tile renderer, not a Blitter or Copper list operation. It reads from a pre-built render list (RenderBlitList_ScreenA_9800 or RenderBlitList_ScreenB_CC00, selected by the active double-buffer pointer) and copies graphics data directly into screen RAM using MOVE.L and MOVE.W instructions.
Each render list entry contains a destination address, an iteration count, and a rendering mode (0–4) that determines the copy pattern. The data is written across four interleaved bitplanes at fixed offsets of $0000, $1F40 (8,000), $3E80 (16,000), and $5DC0 (24,000) bytes — corresponding to the four colour planes of a 320×200 display at 40 bytes per scanline. The five modes handle different tile widths, from single words (16px) to full 80-pixel spans. The list terminates with $FFFFFFFF.
4. Screen Effects – SUB_UpdateScreenEffects_2CF80
Non-entity visual updates. This includes global palette shifts for the Day/Night cycle and managing localised colour timer ticks via ColourTimer1_586, ColourTimer2_56C, and ColourTimer3_570.
5. Growth Logic – SUB_UpdateOrganicGrowth_2CE20
Handles the Spore and Portal spread mechanics that make Wicked unique. Processes pathfinding eligibility for organic growth, expands good or evil terrain, and manages the growth render buffers.
6. Background Drawing – SUB_BulkDrawBackgroundGraphics_2C9E0
With growth logic updated, background elements are drawn into the hidden screen buffer. Given that Step 3 proved to be CPU-driven rather than Blitter-driven despite its name, I cannot confirm Blitter involvement here without examining the code a bit more.
7. Player Fire – SUB_PlayerFireRoutine_2AA3A
Calculates the trajectory and collision of the player’s active shots.
8. Enemy AI – SUB_ManageGuardianAndServantState_2A546
A three-phase guardian lifecycle manager:
Guardian initialization: When the countdown reaches 0, a type-specific setup configures the movement table, spawn timer, and sprite data. Each of the seven guardian types (Devil, Blades, Hand, Hydra, Spider, Firefly, Giant Maggot) has unique timing — from the Maggot’s rapid 33-frame respawn to the Spider’s leisurely 175-frame cycle.
Servant spawning: When the guardian is active, a new servant spawns with a 1-in-16 random chance per gameplay frame (a MOVE.B from SUB_GenerateRandomValue_2E78A masked with $000F). New servants emerge from the guardian’s position.
Death cleanup: When GuardianSpawnCountdown_53E reaches 25, the engine iterates through 12 sprite entries and marks them as destroyed.
9. Updates – SUB_CheckAndTriggerStarMapScroll_2A0F6 (called twice)
Called consecutively twice per frame. Most likely processes one axis per call, or advances the scroll by two steps per frame for smooth parallax motion. The StarMapScrollCounter ($488) and StarMapScrollEventFlag_524 variables control the scrolling state.
10. Player Physics – SUB_PlayerMovementAndCollision_2B640
The largest and most complex subroutine. It handles mode dispatch (gameplay, demo, Eye of Infinity), joystick reading, inertia-based movement, and a full collision detection system with an opcode-driven response interpreter.
Joystick reading happens here, not in Step 2. The routine reads JoystickX ($472) and JoystickY ($474), maps them through TBL_JoystickToInertiaMap_330D2 to obtain inertia delta values, and feeds those into an accumulator system:
- When
GameBias == 1(Normal Bias), joystick values are doubled - Pressing the fire button suppresses all movement (deltas forced to zero while the player aims)
- Inertia accumulates with friction: when no joystick input is detected, the current inertia decays by ±1 per frame
- Inertia is hard-clamped to the range -9 to +9
Collision detection iterates through a table pointed to by LevelRoutinePointer_4FE, with 38-byte entries per entity. Each entity has a collision type byte at offset $14:
- Type
$01(simple physics): applies inertia plus rebound offsets, checks against object-specific X/Y boundaries, and either deactivates the object (if type flag at offset$15is zero) or reverses its inertia vector. - Type
$FF(scripted response): feeds into a bytecode interpreter that reads sequential opcodes fromTBL_CollisionResponseOpcodeTable_32776. Opcodes$21–$28perform operations like clearing inertia, negating direction, computing player-relative direction, and transferring inertia between axes. Any opcode below$21is treated as the terminal displacement value — the final X/Y movement is applied respecting collision direction signs.
After collision resolution, the routine applies a subtle cosmetic touch: PlayerBobToggle_5DA alternates between 0 and 1 every gameplay frame, and on toggle-0 frames the player’s Y position is nudged by ±1 pixel (alternating direction via PlayerBobDirection_5D8). This creates an almost imperceptible hovering animation.
The routine also manages Good Spore carrying (tracking the player’s position at an 8px-aligned X, Y+12px offset), a follower entity system that activates at FirePower >= 7 (with a second follower at FirePower == 9), and special Giant Maggot wrap-around logic.
11. Timers & Spawns – SUB_TimerEvents_2AF78
The engine’s central clock. Far more complex than its name suggests, this routine manages 14 distinct sub-systems:
Evil spore lifecycle: a three-stage state machine: EvilSporeLaunchTimer_4C8 → SporeLaunchDelay_4CA → EvilSporeInFlightFlag_4CC, culminating in a board tile occupancy check and evil portal creation
Eye of Infinity screen logic: sprite updates and tear animation timer (mode 1 only)
Level timer: a two-stage LevelTimerTICK / LevelTimerMAIN system. When LevelTimerMAIN reaches 75, GlobalTickStep_59A is set to 38 to start accelerating Step 1’s periodic timer. Above 75, GlobalTickStep_59A decreases by 2 each tick until it reaches 6 — creating escalating pressure. At 95, the fail condition triggers: growth type is set to evil explosion, growth rate is maximised, and evil portals flood the board
Screen flag events: bit checks on ScreenFlags_542 trigger level-lost logic (bit 1) and evil spore appearance events (bit 2)
Cooldown timer: CooldownTimer_550 countdown → resets 4 projectile object table entries
Guardian respawn: GuardianRespawnTimer countdown → calls SUB_Guardian_2A858
Power-up expiry: PowerUpDurationTimer_528 countdown → calls SUB_ResetTarot_2BF6E
Dynamic object spawning: at GameEventFrameCounter_434 == 544, spawns a power crystal or Tarot item with random trajectory
Power crystal flight: PowerCrystalFlightTimer_514 countdown → converts the flying crystal into a collectible guardian-data entity
Level lost event: at GameEventFrameCounter_434 == 658, queues event $0C
Energy bar visual: smoothly adjusts the central face graphic to reflect current energy, looking up front/back graphics pointers from TBL_EnergyTable1_330AA / TBL_EnergyTable2_330BE
Day/Night cycle: DayNightTimer increments each gameplay frame. At 150 (6 seconds), the cycle flips and GraphicAnimFrameState_446 is set to 1 to trigger the sun/beast rotation animation. At 37 (~1.5 seconds into the cycle), GraphicAnimFrameState_446 is set to 4 — this is the manual injection point for the Tarot card overlay, selecting a random card (0–7) and loading its effect pointer from TBL_PowerCrystalTable_333BA
Input delay timers: InputDelayTimer_538 and JoystickInputCooldownTimer_536 are incremented by 2 each frame
Spore spawning: probabilistic system — evil spores have a 1-in-32 chance per frame during nighttime, good spores a 1-in-16 chance during daytime, with an additional 1-in-16 random gate when searching for a matching portal
12. Spore & Crystal Interactions – SUB_ProcessSporeAndCrystalCollisions_2BD58
Iterates through entity tables from TBL_GoodSporeTable_348F6 to $34BEF (38-byte entries), performing bounding box collision against the player’s position. The hitbox extends 27px right, 8–16px left (depending on a direction flag), 26px down, and a variable amount up (from the entity’s offset $02 field).
Different entity types trigger different responses based on their table address:
- Good Spore (
$348F6or$3491C): setsCarryingGoodSpore_4C6to the table offset, queues event$11 - Power Crystal (
$34968): applies the current Tarot card’s effect:- Star: boosts
FirePowerfrom 8 to 9 - Death: grants an extra life
- Wheel of Fortune: inverts energy (
$5F00minus current) - Hanged Man: adds 8 to
LevelTimerMAIN— pushing closer to the fail threshold of 95. This is a curse, not a blessing - Other cards: set
FirePowertoTarot + 1
- Star: boosts
- Evil Spore (
$34942): queues event$19, then if the spore’s damage value is non-zero andFirePower != 1, applies energy damage (value × 32) viaSUB_CheckEnergy_2A37E
All Tarot effects conclude by resetting follower state and loading a power-up duration from TBL_PowerUpTimerTable_333DA.
13. Score Management – SUB_CheckPointsToAdd_2D4C8
Checks PointsToAdd_48A (which was zeroed at the start of the frame by the dispatcher). If non-zero, the accumulated points from the frame are processed and rendered to the HUD.
Once the score is updated, the VBL interrupt finishes. It pops the five saved game-board words from the stack, restores all CPU registers via MOVEM.L, executes an RTE (Return from Exception), and hands control back to the idle MainLoop dispatcher — for exactly 40 milliseconds, until the next gameplay-eligible VBL fires and the process begins again.
The Abbreviated Path: Level Won / Demo Growth
When LevelWonFlag_50A or GrowthAllowedFlag_520 is set, the engine diverts to LAB_HandleLevelWin_2936C, which executes only 6 of the 13 subroutines — and in a different order:
- TimerEvents (normally Step 11) — runs first, not eleventh
- RenderBitplaneData (Step 3)
- UpdateScreenEffects (Step 4)
- UpdateOrganicGrowth (Step 5)
- BulkDrawBackgroundGraphics (Step 6)
- PlayerMovementAndCollision (Step 10)
Seven subroutines are dropped entirely: the periodic timer (Step 1), the screen-specific input handler (Step 2, which was a no-op anyway), player fire (Step 7), enemy AI (Step 8), parallax scrolling (Step 9), crystal/spore interactions (Step 12), and score management (Step 13). Notably, joystick input polling is also skipped since it happens inside Step 10’s subroutine rather than separately — meaning the player continues to drift on stale inertia values, or the routine internally disables joystick-driven movement via its own flag checks.
What This Design Reveals
Tracing this 13-step loop exposes several core characteristics of Wicked’s game design:
Decoupled timing. Audio runs at 50 Hz, palette effects at ~50 Hz, and gameplay at 25 Hz — all from the same interrupt handler but gated at different tiers. This gives the illusion of silky-smooth visuals while budgeting more CPU time per gameplay frame.
Atomic execution. During gameplay, the SR stays at $2700, preventing any interrupt nesting. The entire 13-step sequence completes as a single indivisible unit. No partial state is ever visible between steps.
Inertia-based physics. Player movement isn’t a direct position mapping — it’s an accumulator system with friction, clamping, and fire-button suppression. This gives the player its distinctive floaty, momentum feel.
Bytecode collision responses. Rather than hardcoding collision behaviours, the engine reads sequential opcodes from a response table, making the collision system data-driven and flexible — different entity types can have entirely different bounce, redirect, or pursuit behaviours without any code changes.
Frame-budget-aware double buffering. The CPU-driven tile copier in Step 3 writes directly to the inactive screen buffer, while the Copper displays the active one. The dispatcher swaps the pointers before the 13 steps begin, ensuring the CPU always writes to the hidden buffer.
Mermaid flowchart:
flowchart TD VBL["⚡ Level 3 VBL Interrupt<br/><i>$29218 — fires at 50 Hz</i>"] ACK["Acknowledge VBL<br/><code>INTREQ ← $0020</code>"] SRCHECK{"GameControlMode<br/>== 0?"} SRLOW["Lower IPL<br/><code>SR ← $2000</code><br/><i>Allow nesting</i>"] SRHIGH["Keep SR = $2700<br/><i>Atomic execution</i>"] subgraph TIER1["🔊 Tier 1 — Audio (50 Hz)"] SAVE1["Save D0-D7/A0-A6"] TAROT["SUB_HandleTarotTimer<br/><code>$2BF96</code>"] ELOCK{"EventLockFlag<br/>$37A78?"} AUDIO_NORM["SUB_ProcessActiveSoundEffects<br/><code>$39294</code>"] AUDIO_INIT["SUB_InitializeAudioAndInput<br/><code>$37A7A</code>"] RESTORE1["Restore D0-D7/A0-A6"] end LOCAL{"LocalControlFlag<br/>$594?"} LOCALEXIT["Clear flag + RTE<br/><i>Eye of Infinity exit</i>"] CTRL{"ControlAckFlag<br/>$584?"} PALETTE_CLR["Clear palette<br/>+ RTE"] CLEAR{"ClearScreenBOOL<br/>$5E4?"} CLEAR_EXIT["RTE<br/><i>Screen clearing</i>"] subgraph TIER2["🎨 Tier 2 — Visual Effects (~50 Hz)"] SAVE2["Save D0-D7/A0-A6"] PAL["SUB_PaletteTimers_TickAndApply<br/><code>$2A3B8</code>"] SUNSET["SUB_ManageSunsetAnimation<br/><code>$2940C</code>"] end TITLE{"GameControlMode<br/>== TitleScreen?"} TITLE_EXIT["Restore regs + RTE<br/><i>Title: palette only</i>"] subgraph FRAMESKIP["⏱️ Frame Skip Gate"] TICK["MaskedFrameTick += 1"] MASK["AND with FrameSkipMask<br/><i>mask = 1 → 25 Hz</i>"] SKIPQ{"Result<br/>!= 0?"} COPPER_ONLY["Set Copper list<br/>+ Restore + RTE<br/><i>Skipped frame</i>"] end subgraph SETUP["📦 Frame Setup"] EYEFLAG["Eye mode?<br/>Set LocalControlFlag"] PUSHVARS["Push 5 game-board words"] CLEARPTS["PointsToAdd ← 0"] SWAP["SUB_SwapScreenBuffers<br/><code>$2A2E2</code>"] end subgraph GUARD["🛡️ Entry Guards"] WONCHECK{"LevelWonFlag<br/>$50A?"} GROWCHECK{"GrowthAllowedFlag<br/>$520?"} end subgraph FULLPATH["🎮 Full Gameplay Path (13 Steps)"] direction TB S01["① Periodic Event Timer<br/><code>$2B5E0</code>"] S02["② Hi-Score/Grave Input<br/><code>$2C194</code><br/><i>No-op for gameplay</i>"] S03["③ CPU Tile Renderer<br/><code>$2A446</code>"] S04["④ Screen Effects<br/><code>$2CF80</code>"] S05["⑤ Organic Growth<br/><code>$2CE20</code>"] S06["⑥ Background Drawing<br/><code>$2C9E0</code>"] S07["⑦ Player Fire<br/><code>$2AA3A</code>"] S08["⑧ Guardian & Servant AI<br/><code>$2A546</code>"] S09["⑨ Star Map Scroll ×2<br/><code>$2A0F6</code>"] S10["⑩ Player Physics<br/><code>$2B640</code><br/><i>Joystick read + inertia<br/>+ collision opcodes</i>"] S11["⑪ Timers & Spawns<br/><code>$2AF78</code><br/><i>14 sub-systems</i>"] S12["⑫ Spore/Crystal Collisions<br/><code>$2BD58</code><br/><i>Tarot effects</i>"] S13["⑬ Score Management<br/><code>$2D4C8</code>"] end subgraph ABBREV["🏁 Abbreviated Path (Level Won)"] direction TB A01["① Timers & Spawns<br/><code>$2AF78</code>"] A02["② CPU Tile Renderer<br/><code>$2A446</code>"] A03["③ Screen Effects<br/><code>$2CF80</code>"] A04["④ Organic Growth<br/><code>$2CE20</code>"] A05["⑤ Background Drawing<br/><code>$2C9E0</code>"] A06["⑥ Player Physics<br/><code>$2B640</code>"] end subgraph EPILOGUE["🔄 Frame Epilogue"] POPVARS["Pop 5 game-board words"] RESTORE2["Restore D0-D7/A0-A6"] FINAL_RTE["RTE<br/><i>Return to MainLoop<br/>for 40ms</i>"] end VBL --> ACK --> SRCHECK SRCHECK -->|"Yes (mode 0)"| SRHIGH --> SAVE1 SRCHECK -->|"No"| SRLOW --> SAVE1 SAVE1 --> TAROT --> ELOCK ELOCK -->|"== 0"| AUDIO_NORM --> RESTORE1 ELOCK -->|"!= 0"| AUDIO_INIT --> RESTORE1 RESTORE1 --> LOCAL LOCAL -->|"!= 0"| LOCALEXIT LOCAL -->|"== 0"| CTRL CTRL -->|"!= 0"| PALETTE_CLR CTRL -->|"== 0"| CLEAR CLEAR -->|"!= 0"| CLEAR_EXIT CLEAR -->|"== 0"| SAVE2 SAVE2 --> PAL --> SUNSET --> TITLE TITLE -->|"Yes"| TITLE_EXIT TITLE -->|"No"| TICK TICK --> MASK --> SKIPQ SKIPQ -->|"Yes (skip)"| COPPER_ONLY SKIPQ -->|"No (run)"| EYEFLAG EYEFLAG --> PUSHVARS --> CLEARPTS --> SWAP SWAP --> WONCHECK WONCHECK -->|"!= 0"| ABBREV WONCHECK -->|"== 0"| GROWCHECK GROWCHECK -->|"!= 0"| ABBREV GROWCHECK -->|"== 0"| FULLPATH S01 --> S02 --> S03 --> S04 --> S05 --> S06 S06 --> S07 --> S08 --> S09 --> S10 S10 --> S11 --> S12 --> S13 --> POPVARS A01 --> A02 --> A03 --> A04 --> A05 --> A06 --> POPVARS POPVARS --> RESTORE2 --> FINAL_RTE style TIER1 fill:#1a1a2e,stroke:#e94560,color:#eee style TIER2 fill:#1a1a2e,stroke:#f5a623,color:#eee style FRAMESKIP fill:#1a1a2e,stroke:#0f3460,color:#eee style SETUP fill:#1a1a2e,stroke:#16213e,color:#eee style GUARD fill:#1a1a2e,stroke:#533483,color:#eee style FULLPATH fill:#0a2e0a,stroke:#4caf50,color:#eee style ABBREV fill:#2e1a0a,stroke:#ff9800,color:#eee style EPILOGUE fill:#1a1a2e,stroke:#607d8b,color:#eee

Leave a Reply