// -----------------------------------------------------------------------------
// 8BITWORKSHOP ASSET LINK DIRECTIVES
// -----------------------------------------------------------------------------
//#link "chr_generic.s"
#include "neslib.h"
// Physics Constants
#define FLOOR_Y 192
#define PLAYER_X 32
#define GRAVITY 1
#define JUMP_FORCE -10
// -----------------------------------------------------------------------------
// PALETTE SETUP
// -----------------------------------------------------------------------------
const unsigned char sprite_palette[16] = {
0x0F, 0x16, 0x27, 0x30, // Palette 0: Black, Red, Orange, White
0x0F, 0x0F, 0x0F, 0x0F,
0x0F, 0x0F, 0x0F, 0x0F,
0x0F, 0x0F, 0x0F, 0x0F
};
// -----------------------------------------------------------------------------
// 2x2 METASPRITES (16x16 pixels)
// Structure per tile: x_offset, y_offset, tile_id, attributes
// Terminated with 128 (0x80)
// -----------------------------------------------------------------------------
const unsigned char metasprite_run1[] = {
0, 0, 0xDC, 0, // Top-Left tile
8, 0, 0xDE, 0, // Top-Right tile
0, 8, 0xDD, 0, // Bottom-Left tile
8, 8, 0xDF, 0, // Bottom-Right tile
128
};
const unsigned char metasprite_run2[] = {
0, 0, 0xE0, 0,
8, 0, 0xE2, 0,
0, 8, 0xE1, 0,
8, 8, 0xE3, 0,
128
};
const unsigned char metasprite_jump[] = {
0, 0, 0xE8, 0,
8, 0, 0xEA, 0,
0, 8, 0xE9, 0,
8, 8, 0xEB, 0,
128
};
// Running animation frame table
const unsigned char* const run_animation[] = {
metasprite_run1,
metasprite_run2
};
// -----------------------------------------------------------------------------
// PLAYER STATE VARIABLES
// -----------------------------------------------------------------------------
unsigned char player_y = FLOOR_Y;
signed char player_vy = 0;
unsigned char is_grounded = 1;
unsigned char anim_timer = 0;
unsigned char anim_frame = 0;
// -----------------------------------------------------------------------------
// FUNCTIONS
// -----------------------------------------------------------------------------
void update_player(unsigned char pad) {
// Jump trigger on A button
if ((pad & PAD_A) && is_grounded) {
player_vy = JUMP_FORCE;
is_grounded = 0;
}
// Apply Velocity & Gravity
player_y += player_vy;
player_vy += GRAVITY;
// Floor Boundary
if (player_y >= FLOOR_Y) {
player_y = FLOOR_Y;
player_vy = 0;
is_grounded = 1;
}
}
void update_animation(void) {
if (is_grounded) {
anim_timer++;
if (anim_timer >= 6) { // Advance frame every 6 ticks (~10 FPS)
anim_timer = 0;
anim_frame = (anim_frame + 1) % 2; // Toggle between frame 0 and 1
}
}
}
void draw_player(void) {
const unsigned char* current_metasprite;
// Select airborne or grounded MetaSprite
if (!is_grounded) {
current_metasprite = metasprite_jump;
} else {
current_metasprite = run_animation[anim_frame];
}
// Render 2x2 MetaSprite to OAM
oam_meta_spr(PLAYER_X, player_y, 0, current_metasprite);
}
// -----------------------------------------------------------------------------
// MAIN PROGRAM LOOP
// -----------------------------------------------------------------------------
void main(void) {
// Write sprite palettes to PPU
pal_spr(sprite_palette);
// Turn on graphics rendering
ppu_on_all();
while (1) {
// Read Controller 1 Input
unsigned char pad = pad_trigger(0);
update_player(pad);
update_animation();
// Clear sprite hardware memory and re-draw
oam_clear();
draw_player();
// Synchronize with NES TV refresh rate (60 FPS)
ppu_wait_nmi();
}
}
Using C with the neslib library in 8bitworkshop lets you prototype game loops, player gravity, and scrolling background mechanics cleanly without writing raw 6502 assembly right away.
Core Technical Concept
To simulate an endless runner on NES:
- Player Position: The player stays fixed horizontally on the X-axis (e.g., $X = 32$) and only moves vertically on the Y-axis when jumping or falling under gravity.
- Scrolling: The NES background hardware fine-scrolls horizontally (
scroll_x++). As background tiles move off-screen to the left, new tiles and obstacles spawn on the right seam. - Collision Detection: Check bounding box collisions between the fixed player character sprite and incoming obstacle coordinates.
[Breakpoint] The code runs, but nothing shows up on the screen.
To handle a character made of 4 hardware tiles ($16 \times 16$ pixels in a $2 \times 2$ grid), you can use MetaSprites or standard relative coordinate drawing functions. neslib provides built-in functions like oam_meta_spr() specifically designed for multi-tile objects.
[Breakpoint] I added logic for character animation and collision, and while the code checked out, the screen just flashed between black and red with nothing rendering. I soon realized I hadn’t linked the default CHR file to the game likely because my original prompt didn’t include details about the CHR data and color palette.
// -----------------------------------------------------------------------------
// METASPRITES
// -----------------------------------------------------------------------------
const unsigned char metasprite_run1[] = {
0, 0, 0x01, 0, // Top-Left
8, 0, 0x02, 0, // Top-Right
0, 8, 0x11, 0, // Bottom-Left
8, 8, 0x12, 0, // Bottom-Right
128
};
const unsigned char metasprite_run2[] = {
0, 0, 0x03, 0,
8, 0, 0x04, 0,
0, 8, 0x13, 0,
8, 8, 0x14, 0,
128
};
const unsigned char metasprite_jump[] = {
0, 0, 0x05, 0,
8, 0, 0x06, 0,
0, 8, 0x15, 0,
8, 8, 0x16, 0,
128
};
const unsigned char* const run_animation[] = {
metasprite_run1,
metasprite_run2
};
The flickering occurs because check_collision() is evaluating to 1 (true) constantly on every frame, causing the game loop to alternate palette color 0 between red (0x16) and black (0x0F).
The Cause: Initial Y-Coordinate Overlap
FLOOR_Yis set to192.player_ystarts at192.player_y + 16equals208.obstacle_yis set toFLOOR_Y + 8=200.
Because (player_y + 16) ($208$) is greater than or equal to obstacle_y ($200$), the vertical collision condition (player_y + 16) >= obstacle_y evaluates to TRUE continuously, even when the obstacle is on the far right of the screen ($X = 240$).
Adjust obstacle_y to sit at FLOOR_Y and refine the bounding box collision logic to require both top and bottom $Y$-axis overlaps:
[Breakpoint] Screen was still back, I finally realized I need to set up color palettes.
The color palette setup is incomplete. At boot, the NES PPU contains random or zeroed palette RAM. Calling pal_col(0, ...) only modifies index 0 (the background fill color), leaving all sprite colors uninitialized or black.
What Is Happening
- Undefined Sprite Palettes: Without initializing sprite palettes using
pal_spr()orpal_bg(), the PPU reads color0x0F(black) for every sprite pixel. The sprites are actually rendering, but they are invisible against the black background. - False Collision Flashing: Because the sprite tiles are invisible, it felt like a random flashing bug. Modifying
obstacle_x -= 2altered how often the invisible obstacle bounding box collided with the player.
In NES hardware, palettes are defined in 16-byte arrays (4 palette groups of 4 colors each). Index 0 of each group is the shared background color.
Add palette initialization inside the main() setup before entering the while(1) loop:
// Define background and sprite palette arrays (16 bytes each)
const unsigned char bg_palette[16] = {
0x0F, 0x00, 0x10, 0x30, // Palette 0 (Black, Gray, Light Gray, White)
0x0F, 0x01, 0x11, 0x21, // Palette 1
0x0F, 0x06, 0x16, 0x26, // Palette 2
0x0F, 0x09, 0x19, 0x29 // Palette 3
};
const unsigned char sprite_palette[16] = {
0x0F, 0x16, 0x27, 0x30, // Palette 0: Red, Orange, White (Player)
0x0F, 0x11, 0x21, 0x30, // Palette 1: Blue, Light Blue, White (Obstacle)
0x0F, 0x09, 0x19, 0x29, // Palette 2
0x0F, 0x0F, 0x0F, 0x0F // Palette 3
};
In 8bitworkshop, comment directives like //link "chr_generic.s" tell the build system to assemble and link the CHR graphics asset bank into the final ROM cartridge. Without it, the NES PPU renders completely blank or garbage graphics banks. This was the final missing link. Here is the working code for character running.