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:
- 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.
- Density second — after speed feels right, add more simultaneous enemies.
- 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]; }
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.6.
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).
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:
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.
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 |
See Also¶
- Input —
PAD_PRESSED/HELD/RELEASEDmacros, 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