Skip to content

Gameplay Patterns

Game design patterns for NGPC: screen flow state machines, pause/menu systems, difficulty pacing, control mapping strategies, and common genre mechanics.


1. Screen Flow and State Machines

1.1 Typical Flow

Most NGPC homebrew games use a linear state machine across a small set of screens:

[INTRO]
  |
  v
[TITLE]
  |-- A pressed -> [GAME]
  |-- OPTION    -> [OPTIONS]
  |
[GAME]
  |-- OPTION    -> [PAUSE]
  |-- died      -> [GAME OVER]
  |-- cleared   -> [STAGE CLEAR] or [GAME OVER]
  |
[GAME OVER]
  |-- check high score -> [HIGH SCORE ENTRY]
  |-- else             -> [TITLE]
  |
[HIGH SCORE ENTRY]
  |-> [HIGH SCORE TABLE]
     |-> [TITLE]

1.2 Implementation Pattern

Each state = a pair of functions (init, update):

typedef enum {
    STATE_TITLE = 0,
    STATE_GAME,
    STATE_PAUSE,
    STATE_GAME_OVER,
    STATE_HIGH_SCORE,
} GameState;

static GameState s_state;

void game_init(void);
GameState game_update(void);   /* returns next state */

void main(void)
{
    ngpc_init();
    s_state = STATE_TITLE;
    title_init();

    while (1) {
        ngpc_vsync();
        ngpc_input_update();

        switch (s_state) {
            case STATE_TITLE: {
                GameState next = title_update();
                if (next != s_state) {
                    s_state = next;
                    if (next == STATE_GAME) game_init();
                }
            } break;
            case STATE_GAME: { /* ... */ } break;
            /* ... */
        }
    }
}

2. Pause System

2.1 OPTION Button Convention

OPTION is the standard pause button on NGPC — it is the only "side button" available and users expect it to pause/unpause. Use PAD_PRESSED(PAD_OPTION) (edge-triggered).

From OPTION: - First press → enter pause - Second press → resume game - During pause: A/B = "resume" or context action

Pause menu from OPTION (second level): - D-Pad to navigate menu entries - A to confirm - B or OPTION to cancel / return to game

2.2 Pause Menu Example

typedef enum { PAUSE_RESUME, PAUSE_RESTART, PAUSE_TITLE } PauseChoice;

static u8 s_pause_cursor;

void pause_init(void)
{
    s_pause_cursor = 0;
    /* draw pause overlay */
    ngpc_text_print(GFX_SCR1, 0, 8, 4, "RESUME");
    ngpc_text_print(GFX_SCR1, 0, 8, 6, "RESTART");
    ngpc_text_print(GFX_SCR1, 0, 8, 8, "QUIT");
}

PauseChoice pause_update(void)
{
    if (PAD_PRESSED(PAD_DOWN))  s_pause_cursor = (s_pause_cursor + 1) % 3;
    if (PAD_PRESSED(PAD_UP))    s_pause_cursor = (s_pause_cursor + 2) % 3;
    if (PAD_PRESSED(PAD_A) || PAD_PRESSED(PAD_OPTION)) {
        switch (s_pause_cursor) {
            case 0: return PAUSE_RESUME;
            case 1: return PAUSE_RESTART;
            case 2: return PAUSE_TITLE;
        }
    }
    return (PauseChoice)-1;   /* no selection yet */
}

3. Difficulty and Pacing

3.1 Density vs Speed

When a game feels too easy or too hard, tune in this order:

  1. Speed first — lower enemy movement speed and fire rate before increasing density. Players can handle a few fast enemies better than a swarm of slow ones.
  2. Density second — after speed feels right, add more simultaneous enemies.
  3. Patterns third — vary attack patterns to maintain interest without just adding numbers.

Permanent saturation = visual fatigue + performance pressure. Always alternate pressure and breathing room.

3.2 Breathing Rhythm

Effective level pacing (shmup example):

[Wave 1: 3 enemies, sparse]  ← easy entry
[Breather: 2-3 seconds, no enemies]
[Wave 2: 5 enemies, mixed patterns]  ← moderate pressure
[Breather: 1 second]
[Wave 3: 8 enemies + formation]  ← high pressure
[BOSS]
[Post-boss: empty field, health drop]  ← reward / recovery

Rhythm rule: calm → pressure → calm → pressure. Two consecutive intense waves without breathing room cause frustration, not challenge.

3.3 Difficulty Scaling

Common patterns:

Parameter Easy → Hard progression
Enemy speed Slower → faster
Fire rate Longer reload → shorter
Enemy count 2-4 → 8-12+ simultaneously
Enemy HP 1 hit → 2-5 hits
AI reaction Delayed → immediate
Pattern complexity Straight lines → curves + spread

Recommended approach: define constants, not magic numbers.

static const u8 SPEED_BY_LEVEL[] = { 1, 1, 2, 2, 3, 3 };
static const u8 FIRE_RATE_BY_LEVEL[] = { 90, 75, 60, 45, 30, 20 };
u8 g_level;

u8 enemy_speed(void) { return SPEED_BY_LEVEL[g_level]; }
u8 fire_cooldown(void) { return FIRE_RATE_BY_LEVEL[g_level]; }

3.4 Fixed vs self-timed frame rate (60 vs 30 fps)

Not every game runs at a fixed 60 fps. Some shipped titles — a snowboarding game and a rail-vehicle simulation among them — self-time: the VBlank ISR does the full per-frame work only if the main loop already consumed the previous frame (a per-frame vsync byte, e.g. 0x4016). If the per-frame work exceeds one VBlank period, the ISR skips every other VBlank and the game runs at 30 fps automatically.

VBlank ISR:  cp (vsync),0xFF ; jr Z, skip   ; last frame not consumed -> skip
             ...full work...  ; ld (vsync),0xFF
main loop:   ld (vsync),0    ; ...update... ; spin until (vsync)!=0
The 0x4016 flag with the cp ...,0xFF ; jr Z guard is a real reverse-engineered pattern; whether it sets the frame rate depends on the per-frame load.

Emulator bug — SOLVED (silicon-calibrated, 2026-07). These games run at 30 fps on silicon but at 60 fps (2x too fast) on ares / BizHawk / this emulator — the timer counts double and the whole game is too fast. A genuine bug common to all NGPC emulators. Root cause: unmodelled cartridge-flash wait-states. Every instruction is fetched from the cart and data tables are read from it; that flash bus is slow, but emulators fetch it for free, so cart code runs ~3.4x too fast. The self-timed games' per-frame work then fits inside one VBlank (60 fps) instead of spilling into a second (30 fps). VBlank-locked games (Fatal Fury) are unaffected — their work fits in one frame regardless.

A calibration ROM run on real hardware (hw_calibration/cpu_calib_v1.ngc) measured the per-class slowdown: short/fetch-bound ops ~3.4x, MUL/DIV ~2.5x, frame length (RASV) correct — the exact signature of a fetch/access wait-state, not an execution-cost bug. (The earlier "VRAM adjustment-circuitry wait-state" idea was disproven first: Cool Boarders spins ~62% of every frame, so it is not CPU-bound and no VRAM cost could cause the 2x. The slow bus is the cart, not VRAM. Also ruled out: clock gear, TI0 rate, prescaler timers, global pacing.)

Fix (four causes). (1) 3 cycles/byte for instruction FETCH from the cart (Machine::cart_wait) — silicon-confirmed. (2) Cart DATA reads are NOT wait-stated (data=0) — a ROM found CRND==RRND, refuting an earlier data=5 guess. (3) MUL/DIV were under-costed (datasheet is a floor) — fixed to the silicon numbers. (4) The residual ~51→30 fps is the game's big per-frame LDIR into a RAM buffer: the datasheet 7 cycles/byte is also a floor, and 14 cycles/byte puts Cool Boarders at 30 fps while Fatal Fury stays at 60 — one fix, both games, confirmed by comparing the in-game timer to a stopwatch. (A cart-flash write is also throttled in active display, but this game writes VRAM in vblank, so that is a separate real effect, not its bottleneck.) Any accurate NGPC emulator needs the cart-flash FETCH wait-state and the silicon LDIR/MUL/DIV costs — the datasheet under-states them.


3.5 Placing events: impose the proportion, randomise only the phase

On 8 to 16 events per level, independent random draws are a design bug. Measured over twelve seeds: the worst case put sixteen obstacles of the same type in a row (a whole track without a single barrier) and lanes at nine against three. An independent draw is allowed to do that — it is its definition — and on so few events that right is paid in levels nobody sees fail before playing them.

  • Where: a regular pitch (length / (n+1)) plus a bounded offset from a hash of the index — regular THEN shifted. A free draw over the whole length clusters in pairs and leaves a third of the track empty, which reads as a bug.
  • What and which lane: ROTATE, do not draw. One barrier in four exactly, lanes by steps of two out of three; chance only picks the PHASE. Use coprime periods (4 and 3): with equal periods the barrier always lands in the same lane on a given track, players learn to hug one side and never see it again. Over 24 seeds: longest same-type run 3, lane imbalance 1, barriers through all three lanes.
  • No draw at run time: seed from the level number. Same level, same obstacles, same places — otherwise no time is comparable and no bench measures anything (and a two-player link needs to send nothing about them).

3.6 Difficulty classes computed from the level, as quantiles — and the rounding trap

Read a level's class from its data (a score such as corner load × duration, computed with the same integer arithmetic in the game and in the offline tool), never write it next to the name. Classes as quantiles of the level set keep the distribution even as levels are added (adding one may move a neighbour one class — accept it).

⛔ A truncated quantile puts everyone in the last class. 3 * 10 / 100 = 0, and the three thresholds with it: all tracks came out VERY HARD, with 16 obstacles each, including the gentle test track. Round: (n * p + 50) / 100 — and compute it in 16 bits (Build Toolchain §8.1b: n * 30u + 50u on a u8 wrapped at 7 levels and deleted one class). Put the class in a probe and compare it with the tool's — two computations of the same thing always drift apart, and counting stars on a screenshot does not tell whether the computation or the drawing is wrong.

3.7 Numbers that can reach ten

'0' + count returns ':' at ten. A "10/10" counter displayed ":/:" — on a screenshot that was taken and looked at, read as an ornament. Compose every count that can reach ten digit by digit. And count the CELLS of a line after adding a field: a new star overwrote the last letter of a 13-character name — a name missing a letter still reads as a name.

4. Control Mapping Patterns

4.1 Edge vs Continuous

Input type Technique Use case
Edge-triggered (just pressed) PAD_PRESSED() Fire, jump, confirm, toggle pause
Held/continuous PAD_HELD() Movement, accelerate, hold shoot
Auto-repeat ngpc_pad_repeat (delay=15, rate=4) Menu navigation

Avoid using PAD_HELD for one-shot actions — it causes rapid repeats. Avoid PAD_PRESSED for movement — it causes jerky stepping.

4.2 Context-Sensitive Controls

Same button, different meaning depending on state:

State A B OPTION
Title Start game — Options
Gameplay Fire/action Secondary Pause
Pause Resume Back Toggle pause
Menu Confirm Back/Cancel Pause
High score entry Confirm char Delete char —

Define behavior per-state in each state_update() function — do not branch on state inside a generic input handler.


5. Genre-Specific Patterns

5.1 Shoot-em-up

Core loop:

halt (VBlank)
→ poll input
→ move player (PAD_HELD direction)
→ fire bullet (PAD_PRESSED or PAD_HELD with cooldown)
→ update enemies (wave system, formation)
→ update bullets (all pools in one pass)
→ collision (bullet vs enemy, enemy vs player)
→ render

Fire rate: use a per-entity cooldown counter rather than PAD_PRESSED:

if (fire_cooldown == 0 && (ngpc_pad_held & PAD_A)) {
    spawn_bullet();
    fire_cooldown = FIRE_RATE;
}
if (fire_cooldown > 0) fire_cooldown--;

Enemy waves: delayed spawn using a monotonically increasing counter:

/* Delays must be in ascending order */
static const WaveEntry wave[] = {
    { 30,  ENEMY_STRAIGHT },    /* frame 30 */
    { 84,  ENEMY_DIAGONAL },    /* frame 84 (spacing >= 54 to avoid overlap) */
    { 138, ENEMY_ZIGZAG },
};

Minimum spacing between spawns at speed_x=1: 54 frames to avoid visual overlap.

5.2 Platformer

Core rules: - Gravity: vy += GRAVITY each frame (typical GRAVITY = 1-2 units) - Jump: vy = -JUMP_STRENGTH on press, only when grounded - Coyote time: allow jump for 4-6 frames after leaving a ledge - Max fall speed: clamp vy <= FALL_MAX to avoid tunneling

Kill zone: use map bounds (world_x < -24 || world_x > map_w * 8 + 24) not camera-relative bounds — enemies must survive offscreen while scrolling.

5.3 Puzzle / Match-3

Core events: 1. Player selects a piece 2. Player swaps with an adjacent piece 3. Check for matches (3+ in a row/column) 4. Remove matched pieces (with animation frame) 5. Apply gravity (pieces fall) 6. Check for chain combos (repeat from step 3) 7. Check win/lose condition

Two common modes: - Move limit — fixed number of swaps; combos return credits. - Time trial — decreasing timer; clearing blocks adds time.

Scoring tip: chain combos multiply points — reward skilled play, not just any match.

5.4 Snake / Grid-Based

Key rules: - Movement on a tile grid — update direction on PAD_PRESSED, not held - Ignore reverse direction input (UP when moving DOWN = no-op) - Wrap-around edges: x = (x + 1) % GRID_W - Speed control: update position every N frames (N decreases as score increases)

/* Update only every SPEED_FRAMES */
static u8 s_move_timer;
if (++s_move_timer >= speed_frames) {
    s_move_timer = 0;
    move_snake();
}

5.5 Racing / Top-Down

Physics pattern: - Speed: speed = min(speed + ACCEL, MAX_SPEED) when A held - Brake: speed = max(speed - BRAKE, 0) when B held - Steering: modifies direction (angle) progressively - Terrain slowdown: if (on_grass) speed = max(speed - 2, 0)

/* Progressive turning */
if (PAD_HELD(PAD_LEFT))  direction = (direction - TURN_RATE) & 0xFF;
if (PAD_HELD(PAD_RIGHT)) direction = (direction + TURN_RATE) & 0xFF;

5.5b Racing / Forward-Facing Pseudo-3D

Full technical details: Effects and Raster §8.

For a forward-facing road with depth-scaling illusion (Mode 7-style, no hardware scaling on NGPC), a confirmed approach from commercial code:

Depth → screen position (both axes from one formula):

#define ZOOM_STEP  25   /* = 200px screen height / 8px tile — constant, all types */

s16 zoom = (range - (entity_z - camera_z)) / ZOOM_STEP;
/* zoom < 0 → cull (behind or beyond far plane) */

u8 sx = 80 - persp_x[zoom];              /* X: road edges converge to center */
u8 sy = (u8)(0x50 - persp_y[zoom]);      /* Y: horizon at top, road at bottom */
/* cull if sy < 8 or sy >= 144 */

Z-sort display list (insertion sort): Objects are inserted into a sorted list at activation time (O(n), n≤20). Traversing in Z order gives correct back-to-front rendering (painters algorithm) for free, without a separate sort pass each frame.

Z culling:

if (entity_z + z_extent < camera_z) → cull;   /* passed behind (large objects) */
if (entity_z - camera_z < 0)        → cull;   /* behind camera origin */
if (entity_z - camera_z > range)    → cull;   /* beyond far plane */
z_extent allows large objects (long vehicles, buildings) to remain visible even when their origin Z is behind the camera plane.

Entity struct (32 bytes):

struct Entity {
    void  (*render)(Entity *);   /* +0  NULL = free slot */
    u8     _pad[4];
    u32    z;                    /* +8  world Z (depth) */
    u8     type;                 /* +12 zoom table selector */
    u8     visible;              /* +13 0 = skip render */
    u8     _pad2[2];
    u32    z_extent;             /* +16 for back-plane culling */
    u8     data[8];              /* +20 gameplay-specific */
};   /* 32 bytes total */
/* Pool: 20 entities */

Forward motion: advance camera_z by player speed each frame, scroll SCR1 Y register at the same rate. Horizon layer (SCR2) scrolls at a fraction of road speed for parallax depth cue. See Effects and Raster §8.5.

Road curve / widening (the BG side of the illusion): beyond the sprite depth system above, the ground/scenery is warped by a per-scanline horizontal line-scroll of both scroll planes (Timer-0 HBlank ISR, or MicroDMA — ~0 CPU/line). Each visible scanline reads its own SCR1_X/SCR2_X from a 152-entry double-buffered table; a quadratic curve generator fills the next frame's table. This is what makes the rails fan out / converge. Full mechanism: Effects and Raster §8.1–8.3 and §8.6.

Complete forward-view racer recipe, from a finished game on this machine — corners that arrive (per-scanline look-ahead), hills, the five causes of a flickering horizon, objects on the road, the driving model, contact in world units, the CPU budget — is in Pseudo-3D Road §7–§14.

5.6 Adventure / Overworld

Screen transition: move off-screen edge → fade → load new screen.

Economy / inventory pattern: - Store quantities as u8 or u16 counters - Clamp to MAX_INVENTORY on pickup - Display via ngpc_text_print_num() on HUD

Event system: array of { trigger_type, condition, action } — evaluate per-frame or on room entry. Random events: use ngpc_random() with probability thresholds.

5.7 Roguelike / Procedural Dungeon

Reliable on-device procedural rooms come from structure, not free randomness. The robust shape for NGPC (best reliability / RAM / dev-time trade-off) is a two-pass pipeline backed by presets in ROM and compact masks in RAM:

layout (rooms, exits, semantic role)  →  pick interior PRESET  →  furnish (decor/enemies/items)  →  validate + repair

1. Unified ground truth = compact occupancy masks. Represent a room as bit masks, one bit per cell, shared by every system (collision, spawn validation, BFS, stair placement). For a 16×16 metatile room that is 32 bytes each — negligible RAM:

u8 solid_mask[32];        /* blocks movement (walls, pillars, solid decor) */
u8 hazard_mask[32];       /* pits, spikes, water — damage / block differently */
u8 spawn_forbid_mask[32]; /* no-spawn zones (near entry, on hazards, etc.) */

2. Preset library in ROM, not atomic random walls. A small catalogue of authored presets per semantic role (ENTRY, TRANSIT, COMBAT, TREASURE, SAFE, STAIR) is far more reliable than placing random wall rectangles. Each preset embeds its exits_mask, occupancy masks, hazard layout, and socket lists (enemy / item / decor anchor cells). Hazards (pits, water) are part of the preset, not added free-hand, and each preset guarantees at least one safe path between all required exits. Only allow explicitly compatible preset pairs (e.g. don't combine interior walls + water + voids unless that combination is a validated preset).

3. Furnish via sockets and anchors, not free cells. Decor snaps to room anchors (corners, wall edges, near pillars); enemies/items use the preset's socket lists. Follow the level structure for pacing: loot in dead-ends, enemies at junctions, traps in corridors. Keep spawns off solid/hazard and a minimum distance from the entry.

4. Always validate with BFS, then repair — don't re-roll blindly. After generation, run a flood fill on the metatile grid to check: all required exits reachable from entry; entry reaches the stair; stair not on hazard / in a cul-de-sac; no socket on solid or hazard; no hazard isolates a required exit. On failure, walk a repair fallback chain (remove blocking decor → simplify the hazard preset → switch to a simpler layout → plain room) rather than a global re-roll loop.

5. Determinism + offline testing. Generate from a seed so a run is reproducible (replay, debugging). Constraint-based techniques (WFC) are best used offline to help author a bank of valid presets, then ship only the finished masks in ROM — not at runtime. Run thousands of seeds offline to measure preset frequency, fallback rate, and difficulty before shipping.

Related grid mechanics that pair well with this: pushable blocks (keep state in RAM, check collision_at(target) for both the actor and the block before moving both; a push into a pit consumes the block) and lock/trigger gates (a tile held by the player, an enemy, or a pushable opens a door; persist the held/open state per room so it restores on re-entry — and only place a trigger if the room actually has something able to hold it, or you create a softlock).


5.8 A ghost car in 8 bytes per track

A time-attack "ghost" does not need a replay. Record the time at 15 checkpoints plus the finish = 16 portions of distance. At a new best time, quantise each portion's duration into a weight of 1..15 (carrying the rounding error from portion to portion), two weights per byte: 8 bytes per track, 128 bytes for 16 tracks — inside the existing save block. The reader normalises the sum of weights to the exact final time and interpolates the position between checkpoints: no floats, no second physics.

The ghost follows a simple centre line (it does not replay the steering), reaches the line at the exact record time, disappears out of range like the normal rival, never collides, pushes or causes a defeat. Same silhouette as the rival with its own palette — it does not pretend to be the car used for the record. A slower time keeps the old reference; a best time without usable checkpoints clears it; aborted, linked or special races create none. Cost on a race loop: ~21 000 cycles per turn (≈0.2 frame). Limit: an acceleration or a stop inside one portion is smoothed away (max 0.35 s deviation from the real checkpoints on synthetic tests).

5.9 Sharing a result: a QR code in 16 tiles

A result can leave the console without link cable or network: encode it as a QR code drawn with 16 tiles. Each module is 4×4 px, so an 8×8 tile holds 2×2 modules — 2⁴ = 16 possible patterns, built once into 16 character slots (256 bytes); the symbol is then a plain tilemap. Two opaque colours avoid any interaction with the plane behind.

  • Version 2-M (25×25 modules): 28 data + 16 Reed–Solomon bytes; with a 4-module quiet zone (rounded to an even count: 4 top/left, 5 bottom/right) = 34×34 modules = 17×17 tiles = 136×136 px — room left for a line of text.
  • Version 3-L (29×29): 55 data bytes, enough for a full HTTPS URL in alphanumeric mode (upper case only: HTTPS://HOST/PATH/… ≤ 70 characters) so a phone camera offers to open it; 38×38 modules ×4 = 152×152 px, the whole screen height, 19×19 map cells.
  • Payload example: a text prefix, a player name (1–8 of A–Z/0–9) and 15 bytes Base32 (rules revision, track, car, upgrades, flags, 24-bit VBlank ticks, contest id, session counter, CRC-16/CCITT-FALSE over the ASCII prefix and the bytes). A CRC is not a signature: a server must recompute rankings from time and categories and never trust a "personal best" flag.
  • Fixed mask 0: skips computing the eight penalty scores; readers decoded it, but it is not the normative lowest-penalty mask — test on real LCDs before calling it robust.
  • Cost: encoder ~1.6 KB of ROM, 22 bytes of stack; 600 000 – 1 000 000 cycles (~100–170 ms) — several frames, computed once when the screen opens, never during a race.
  • Validate by calling the ROM's own encoder in an emulator on dozens of vectors and comparing every module with a reference QR library, then decode the native 160×152 capture with two independent decoders. Hide result sprites before showing the code (a clock left on top of the symbol broke it).
  • Saturate the time counter instead of letting it wrap, and refuse to export a saturated time.

5.10 Off-screen enemy indicators

A game designed for a screen that showed the whole play area loses information on a smaller window. A small pointer on the edge of the view for each enemy that is entirely outside it may give it back:

  • 3 tiles and the flips give 8 directions: one pointing right (tip on the right edge, centred vertically), one pointing down, one into the bottom-right corner; H/V flips give the others. A tip about 4 × 7 pixels inside the 8×8 cell, light with a dark outline, reads on light and dark floors.
  • Position: clamp the enemy's centre to the view; the pointer slides along the edge as the enemy moves, and uses the corner tile when it is outside on both axes.
  • Which enemies: those that move or watch (guards, cameras…); not bosses, projectiles or invisible actors. Disable them in modes with their own view (binoculars, maps).
  • Cost: note "no piece visible" inside the existing draw loop (compare the piece counter before and after an actor), store the actor's index, and compute pointers only for those — usually 0 to 3. A first version that re-tested every actor (type test, image lookup, signed 16-bit maths) made a scene at its budget edge lose ~2.5× more frames than the final one (see Measuring Performance §2.9).
  • Sprite budget: post them last, only in the slots left — never instead of a game sprite. An off-screen actor posts no pieces, so its pointer uses a slot it freed.
  • ⛔ Compute the centre in signed arithmetic: the image of a flying actor whose top is far above its anchor went "below zero" in unsigned maths, and the pointer pointed the wrong way.
  • A run-time flag to switch them off is useful for benches, and possibly as a menu option.

6. Entity Management Patterns

Cross-genre techniques for managing pools of game objects (enemies, bullets, particles, sprites) within a fixed per-frame time budget.

6.1 Per-Frame Update Budget

Cap entity updates at a fixed count per frame (e.g. 30/frame). Entities not reached this frame hold their last state and are processed on a following frame. This bounds worst-case main-loop cost and prevents dropped VBlanks (frame overruns) at peak entity load — predictable slowdown instead of a missed sync.

6.2 Static Z-Order via Insertion Sort at Spawn

Insertion-sort each entity into a depth-sorted linked list at spawn time (O(n) per insert, no per-frame sort). The OAM builder then walks the list in order and emits sprites back-to-front for free — zero per-frame sort cost. Best when an entity's depth is fixed for its lifetime; re-insert on the rare depth change.

6.3 ROM Dispatch Table for Entity State

Replace switch/case state cascades with an O(1) jump through a ROM table:

sll  3,index           ; index *= 8 (entry stride)
ld   XBC,(table+index) ; load handler address
call T,XBC             ; indirect call

A JP/JRL-table variant works the same way. Constant cost regardless of state count, versus a switch whose cost grows with the number of cases.

6.4 "This"-Pointer Entity Model

Keep the current entity's base address in XIZ while updating it. The state handler is a function pointer stored at offset +0x00 of the entity struct, so a state transition is a single store:

ld (XIZ+0x00), new_fn   ; switch handler, no parameter threading

The handler reads/writes the rest of the struct through XIZ, so no entity pointer needs to be passed as a parameter.

6.5 Ring-Buffer Allocation

Allocate entities/OAM slots from a ring buffer: advance a write pointer on each alloc and wrap at the end of the pool. O(1) allocation with no free-slot scan. Ideal for short-lived, high-churn objects (bullets, particles) where overwriting the oldest live slot on wrap is acceptable.

6.6 Despawn Discipline — Killing an Entity Does Not Free Its Sprite

A generic entity pool separates logic from rendering: the pool tracks an active/alive flag, and a separate draw pass turns live entities into sprites. A minimal kill is therefore just:

#define entity_kill(e)  ((e)->active = 0)   /* logic only — touches NO sprite */

This is correct only if the render side has a matching discipline. On its own, active = 0 stops the entity from being updated and drawn, but its last OAM slot keeps drawing the dead object and stays allocated — an invisible-to-logic "zombie" that leaks the 64-slot budget (see Sprites-and-OAM §10, OAM slot leak). Whenever you free an entity you must also release its sprite, via one of two disciplines:

  • Owned slot + hide on kill: each entity holds a fixed OAM slot (or slot range); kill hides it (ngpc_sprite_hide / ngpc_soam_hide) as well as clearing the flag.
  • Clear-then-redraw: hide the whole used OAM range at the top of each frame, then re-emit only live entities. Dead ones are simply never drawn — nothing to hide.

The trap is shipping a pool where kill flips the flag, the draw loop skips inactive entities, and neither side ever hides the slot. It looks correct — dead entities vanish from logic — but sprites accumulate until spawns silently fail. If your entity module's kill is logic-only, document which discipline the game side must provide.


Quick Reference

Pattern Notes
State machine Init/update pair per state, switch on enum
OPTION = pause Universal NGPC convention
Edge-triggered PAD_PRESSED() — one-shot actions
Held PAD_HELD() — movement, hold-fire
Menu auto-repeat ngpc_input_set_repeat(15, 4)
Pacing rule calm → pressure → calm → pressure
Speed before density Tune enemy speed first, then add more enemies
Fire cooldown Per-entity counter, not edge detection
Wave spawn spacing ≥ 54 frames at speed_x=1 to avoid visual overlap
Kill zone: platformer Map bounds (not camera-relative)
Snake reverse guard Ignore input opposite to current direction
Racing turn Progressive angle change per frame
Chain combo reward Multiply points per chain step
Update budget Cap at fixed count/frame (e.g. 30); rest hold state
Static Z-order Insertion-sort into depth list at spawn, no per-frame sort
State dispatch sll 3,index; ld XBC,(table+index); call T,XBC — O(1)
"This" pointer Entity base in XIZ, handler fn ptr at +0x00
Ring-buffer alloc Advance + wrap pointer, O(1), no free-slot scan
Despawn discipline kill must free the sprite too (hide slot OR clear-then-redraw), else OAM leak

See Also

  • Input — PAD_PRESSED/HELD/RELEASED macros, ngpc_input module, auto-repeat
  • Collision — AABB tests, bullet pool, tilemap collision
  • Game Loop — Main loop structure, VBlank sync, state machine integration
  • Storage and Saves — High score save, flash write timing
  • Effects and Raster — Pseudo-3D perspective scaling full tech (§8), persp_x/y tables, pipeline