How I wrote my first game for Dendy

What is Dendy? What do kids love so much? This is an electronic game! Oooh, dandy…

I think that many readers of Habr had one of the many clones of Dendy (or rather the Famicom console). I’m no exception in this regard, and I even managed to save my console from childhood (but the cartridges were lost:().

Photo taken from a review of my old dandy (it is in good condition and even works)

Photo taken from a review of my old dandy (it is in good condition and even works)

But we have gathered here not only to be nostalgic, but to directly discuss the development of games for dandies. And here I also have something to tell.

A brief history of the game’s creation

The idea of ​​​​creating my own game for dandies appeared to me for quite a long time, but I only took up this issue closely at the end of 2022.

The main incentive for developing the game was my desire to create my own expansion modules for the console, since they were practically never released for the dandy, and I have many ideas and plans for this (for example, a gamepad with more keys, a module for connecting a regular keyboard, a module access to the Internet (sic!), etc.).

Having more or less mastered the course of articles in 2-3 weeks, at the end of January 2023 it was decided to develop a step-by-step strategy about steampunk mechs to consolidate skills. I thought I would limit myself to creating minimal gameplay, but development went a little further.

As a result, active development of the game began in early February and continued until mid-April.

I wanted to announce the game back in the summer, since the basic mechanics are already functional and it is passable (although the game does not have a specific ending, you can just go through all the levels and upgrade your furs). There is even a try-on open world, but it will be completely redrawn and improved.

Current state of combat mode gameplay

Current state of combat mode gameplay

Features of game development for Dandy

Before starting development, the question of choosing tools always arises. I settled on the following set of working programs:

  • CC65 – C language compiler for processors MOS 6502 (still supported)

  • Visual Studio Code – used as the main editor in combination with CC65 and a batch file (I will give the batch file code below) to build the project

  • YY-CHR is an excellent editor for creating pixel art in the format of old consoles (you can open the ROM file of the game and look at the sprites that are used there)

  • NEXXT v0.20.0 – a program for setting backgrounds from a ready-made tileset and creating metasprites (a large sprite consisting of several basic 8×8 pixel sprites)

  • GIMP – I use it to prepare images and convert them into .BMP in edible form for YY-CHR

The choice of the CC65 compiler is a little controversial (it has a weak code optimizer and sometimes it can produce unobvious assembly code), but it appeared in the series of articles that I studied on, so I settled on it. More promising is the use of LLVM-MOS SDK, which allows you to develop games for dandy in modern languages ​​using abstractions (this is realized through the use of a very powerful optimizer) and, according to the developers, allows you to completely get rid of assembly language inserts (even for such narrow moments, like processing a zero sprite (Sprite Zero Hit)).

In addition, CC65 requires rather complex configuration of the rom configuration and memory distribution between segments, but all this + is well documented. Here is an example of my project configuration (nes.cfg):

Hidden text
EMORY {
#RAM Addresses:
    # Zero page
    ZP: start = $00, size = $100, type = rw, define = yes;
	#note, the c compiler uses about 10-20 zp addresses, and it puts them after yours.
	
	OAM1: start = $0200, size = $0100, define = yes;
	#note, sprites stored here in the RAM
	
	RAM: start = $0300, size = $0400, define = yes;
	#note, I located the c stack at 700-7ff, see below

#INES Header:
    HEADER: start = $0, size = $10, file = %O ,fill = yes;

#ROM Addresses:
    # Используется половина PRG ROM
    #PRG: start = $c000, size = $3ffa, file = %O ,fill = yes, define = yes;
    # Используется вся PRG ROM
    PRG: start = $8000, size = $7ffa, file = %O ,fill = yes, define = yes;

    # Hardware Vectors at end of the ROM (тут хранятся адреса обработчиков прерываний)
    VECTORS: start = $fffa, size = $6, file = %O, fill = yes;

#1 Bank of 8K CHR ROM (Тут хранятся спрайты)
    CHR: start = $0000, size = $2000, file = %O, fill = yes;
}

SEGMENTS {
    HEADER:   load = HEADER,         type = ro;
    STARTUP:  load = PRG,            type = ro,  define = yes;
    LOWCODE:  load = PRG,            type = ro,                optional = yes;
    INIT:     load = PRG,            type = ro,  define = yes, optional = yes;
    CODE:     load = PRG,            type = ro,  define = yes;
    RODATA:   load = PRG,            type = ro,  define = yes;
    DATA:     load = PRG, run = RAM, type = rw,  define = yes;
    VECTORS:  load = VECTORS,        type = rw;
    CHARS:    load = CHR,            type = rw;
    BSS:      load = RAM,            type = bss, define = yes;
    HEAP:     load = RAM,            type = bss, optional = yes;
    ZEROPAGE: load = ZP,             type = zp;
	OAM:	  load = OAM1,			 type = bss, define = yes;
	ONCE:     load = PRG,            type = ro,  define = yes;
}

FEATURES {
    CONDES: segment = INIT,
        type = constructor,
        label = __CONSTRUCTOR_TABLE__,
        count = __CONSTRUCTOR_COUNT__;
    CONDES: segment = RODATA,
        type = destructor,
        label = __DESTRUCTOR_TABLE__,
        count = __DESTRUCTOR_COUNT__;
    CONDES: type = interruptor,
        segment = RODATA,
        label = __INTERRUPTOR_TABLE__,
        count = __INTERRUPTOR_COUNT__;
}

SYMBOLS {
    __STACK_SIZE__: type = weak, value = $0100;      # 1 page stack
	__STACK_START__: type = weak, value = $700;
}

And here is the text of the batch file:

Hidden text
:: Запускать компиляцию из директории проекта такой командо:
:: start /b  %cc65bat% <имя_файла_без_разширения>
:: %cc65bat% - имя системной переменной, котороая хранить путь к этому батнику (можно назвать как удобно)
@echo off

set name=%~1

:: Переводит в .asm
:: -Oi - опитмизатор
cc65 -Oi %name%.c --add-source
:: Из .asm компилирует объектный файл
ca65 reset.s
ca65 %name%.s
ca65 asm4c.s
::  Линкует файлы
 ld65 -C nes.cfg -o %name%.nes reset.o %name%.o asm4c.o nes.lib
:: -C указывает использовать конфиг файл
:: ld65 -C nes.cfg -o %name%.nes reset.o %name%.o nes.lib

del *.o

start D:\Programs\emulators\fceux-2.6.4\fceux.exe %name%.nes

The presented batch file compiles, assembles and runs the assembled file in the selected emulator

After compiling an empty project and mastering the basic capabilities of the consoles, it was necessary to think through the architecture of the project. I haven’t written large projects in bare C before, so I tried as best I could to make the project modular. So I settled on the system Game modes (tell me the correct term in the comments), those. at one point in time, the game is always in one of the modes (start screen mode, open world mode, dialogue mode, battle mode, etc.). Here is an example of a combat mode (implements an infinite loop, the iteration of which implements one player or AI move):

Hidden text
void start_battle_mode (void) {
    
    All_Off ();
    change_background_palette ();
    PPU_ADDRESS = 0x20;
	PPU_ADDRESS = 0x00;
    // Выводит на экран поля необходимые для боя
    UnRLE(BATTLE_BG);

    draw_battle_map ();

    PPU_ADDRESS = 0x00;
    PPU_ADDRESS = 0x00;
    All_On ();

    // Инициализируем мехов для текущего уровня
    initialization_of_mechs ();
    // Инициализируем прочность деталей мехов перед боем максимальным значением
    set_all_parts_to_maximum_value ();
    // Подсчитываем сколько мехов участвует в текущей битве
    number_of_mechs_in_battle = levels_info [current_location][0];
    // Выводим на поле всех мехов на стартовые позиции
    draw_all_mechs ();
   
    // Начинаем бой с хода меха игрока
    index_selected_mech = 0;
    p_selected_mech = &mechs[0];
    
    // Начисляем игроку очки действия
    p_selected_mech->action_points_ = 
    pilots [p_selected_mech->pilot_index_].action_points_;
    // Начисляем игроку очки действия
    p_selected_mech->action_points_ = 
    pilots [p_selected_mech->pilot_index_].action_points_;

    // Начинаем бой с режима меню
    game_mode = MENU_MODE;
    parameters_shown = false;
    submenu_shown = false;
    battle_menu_shown = false;
    
    // Внутри цикла нельзя менять p_selected_mech и index_selected_mech
    while (1) {
        Wait_Vblank ();
         
        
        if (parameters_shown == false) {
            All_Off ();
            p_mech = p_selected_mech;
            draw_all_parameters ();
            clear_status ();
            hide_weapon_submenu ();
            PPU_ADDRESS = 0x00;
            PPU_ADDRESS = 0x00;
            All_On ();
            parameters_shown = true;
        }

        switch (game_mode) {
            case MENU_MODE:
                Wait_Vblank ();
                 
                //  Выводим количество очков действия текущего меха
                value = p_selected_mech->action_points_;
                draw_action_points ();
                PPU_ADDRESS = 0x00;
                PPU_ADDRESS = 0x00;
              
                start_menu_mode ();
                game_mode = pointer_position + ATTACK_MODE;
                
                break;
            case ENEMY_MOVE_MODE: // Тут выполняется ИИ врага
                start_enemy_move_mode ();
                game_mode = END_OF_TURN_MODE;
                victory_conditions_check ();
                break;

            case ATTACK_MODE:
                start_attack_mode ();
                // Возвращаемсяв меню выбора действий
                game_mode = MENU_MODE;
                // Проверяем условия победы
                // В случае победы или поражения меняет game_mode
                victory_conditions_check ();
                break;

            case MOVE_MODE:
                move_mech ();
                game_mode = MENU_MODE; 
                break;
            
            case EQUIP_MODE:
                // Пока режима использования предметов нет, возврат в меню
                game_mode = MENU_MODE; 
                break;

            case VIEW_MAP_MODE: // Режим осмотра карты
                start_view_map_mode ();
                parameters_shown = false;
                submenu_shown = false;
                game_mode = MENU_MODE;
                break;
            
            case END_OF_TURN_MODE:
                // Записываем случайных номер кадра в качестве случайного числа
                // Это нужно, так ход врага происходит за случайно время
                // random_number = Frame_Count;
                // Инициализируем заново генератор случайных чисел
                srand (Frame_Count);
                // Заканчиваем ход текущего меха
                start_end_of_turn_mode ();
                // Запускает меню уже для следующего меха
                if (index_selected_mech == 0)
                    game_mode = MENU_MODE;
                else
                    game_mode = ENEMY_MOVE_MODE;
                break;

            case THE_BATTLE_CONTINUES_MODE:
                game_mode = MENU_MODE;
                break;

            case THE_BATTLE_IS_WON_MODE:
                hide_mechs ();
                // Тут выполняем сценариц победы
                p_text      = LOCATION_VICTORY_END_TEXT [current_location]; 
                number_of_lines = 1;
                start_dialog_screen_mode_mode ();

                game_mode = END_OF_BATTLE;
                break;

            case THE_BATTLE_IS_LOST_MODE:
                hide_mechs ();
                // Сценарий проигрыша
                p_text      = LOCATION_LOSING_END_TEXT [current_location]; 
                number_of_lines = 1;
                start_dialog_screen_mode_mode ();

                game_mode = END_OF_BATTLE;
                break;

            case END_OF_BATTLE:
                // Запускаек режим оценки результатов битвы
                // Оценка результатов и возврат на карту мира или в диалоговое окно
                start_end_of_battle_mode ();
                return;
                break;
        }  
    }
}

I don’t see the point in explaining in detail the internal structure of the console and analyzing the code line by line, since even on Habré there are a whole bunch of articles on developing programs for the NES (either in C or in assembler). Therefore, further I want to dwell only on interesting points.

End of Frame Interrupt (NMI)

This is the most important thing when developing games for dandy, since all work with graphics (access to video memory registers) can only be done while the beam is returning to the initial position (upper left corner of the screen). This is a very short period of time. The rest of the time we can carry out all other calculations (but we can also prepare the frame in advance by unloading it into RAM, and at the moment of interruption, loading it into video memory from the buffer).

But I want to talk about such a thing as the function of waiting for the end of the frame. And you have to constantly wait for the end of the frame, since if you forcibly interrupt the rendering of the frame (by turning the rendering off and on), you get an ugly jerk in the image. Therefore, you always have to wait until the end of the frame to change the background. To do this I use a special function in assembler _Wait_Vblank():

Hidden text
_Wait_Vblank:
	LDA _Frame_Count
	@loop:
		CMP _Frame_Count
		BEQ @loop
	RTS

This function waits for the frame counter value to change, since the frame counter is incremented each time the end-of-frame interrupt is triggered. Here is the end-of-frame interrupt (NMI) function:

Hidden text
void NMI (void) {
    ++Frame_Count;
    OAM_ADDRESS = 0;
    OAM_ADDRESS = 0;
    OAM_DMA     = 0x02; // push sprite data to OAM from $200-2ff

	// Сброс скрола
    SCROLL = 0x00;
    SCROLL = 0x00;
    // Выход из прерывания
    // Без него не получится реализовать корректный выход из прерывания
    asm ("rti");
}   

The interrupt handler resets control registers and loads a buffer with information about sprites into video memory (OAM_DMA = 0x02;).

Here 0x02 points to the RAM address where the 256 byte memory block begins. It stores information about 64 sprites. 4 bytes per sprite: coordinates, tile number and attributes.

Clarification. A detailed structure of NES memory can be found in a bunch of articles on Habré (at the end I will provide a list of useful links), or better yet, on nesdev.org (the best resource for NES development). I don’t want to overload my article with publicly available information, but I will try to highlight several interesting points that may not be obvious during development.

Change the background without stopping graphics output

When creating a game with an interactive background (flickering fire, displaying text, numbers, etc.), you constantly have to edit the background tiles (change one tile for another).

The most obvious and simple way to edit the background is to temporarily disable graphics output via control registers. This is what the functions for turning off and on graphics output look like in my case:

Hidden text
void All_Off (void) {
    Wait_Vblank (); // wait till NMI
	PPU_MASK = 0x00;
}
void All_On (void) {
    Wait_Vblank (); // wait till NMI
	PPU_MASK = b0001_1110;
}

The functions wait for the end of the frame. This is necessary to prevent the background from jerking when the graphics are turned on (it looks very unpleasant). Let’s also take a closer look at the control registers to make it clear how disabling graphics works:

PPU_CTRL = b1001_0000; /*
            |||| ||||
            |||| ||++- Выбор базовой таблицы имен
            |||| ||    (0 = $2000; 1 = $2400; 2 = $2800; 3 = $2C00)
            |||| |+--- VRAM address increment per CPU read/write of PPUDATA
            |||| |     (0: add 1, going across; 1: add 32, going down)
            |||| +---- Sprite pattern table address for 8x8 sprites
            ||||       (0: $0000; 1: $1000; ignored in 8x16 mode)
            |||+------ Background pattern table address (0: $0000; 1: $1000)
            ||+------- Sprite size (0: 8x8 pixels; 1: 8x16 pixels – see PPU OAM#Byte 1)
            |+-------- PPU master/slave select
            |          (0: read backdrop from EXT pins; 1: output color on EXT pins)
            +--------- Generate an NMI at the start of the
                        vertical blanking interval (0: off; 1: on)
*/

	PPU_MASK = b0001_1110; /*
                |||| ||||
                |||| |||+ - включает режим в оттенках серого
                |||| ||+ - включает показ фона в крайнем левом столбце
                |||| |+ - включает показ спрайтов в крайнем левом столбце
                |||| + - включает показ фона
                |||+ - включает показ спрайтов
                ||+ - Emphasize red (green on PAL/Dendy) (0: off; 1: on)
                |+ - Emphasize green (red on PAL/Dendy) (0: off; 1: on)
                + - Emphasize blue (0: off; 1: on)
                */

From the code above it is obvious that by editing the register PPU_MASK You can control the display of sprites and background tiles. Everything else is controlled in approximately the same way. There are much fewer control registers here than in the same STM32.

So. After turning off the screen, we can edit the background for as long as we want, but the player will see a black screen all this time, and this is ugly. Even if you manage to edit the background during one frame, the screen will still “flash” black. It’s very noticeable.

But turning off graphics output can be avoided if you manage to replace the necessary background tiles during the beam return time. This works if you need to replace up to 16 tiles, and if more, they will not be edited correctly (random tiles will appear on the screen due to lack of beam return time).

If you need to display more than 16 tiles without turning off the screen (for example, to display a text field on top of the background), it is enough to divide the displayed tiles into groups of up to 16 tiles. And display each group at the end of a separate frame. There’s even some aesthetics to it. Text output this way appears line by line with a drop-down menu effect.

Clarification. In my game, initially I actively used turning off the screen for extensive background editing, so as not to get bogged down in details and speed up development. Therefore, in many places in the game I stop graphics output, but in future versions I hope to get rid of this.

Output of pre-prepared backgrounds in the program

This is what the interface of the background editing program looks like:

On the right is the selected tileset, each tile of which can be used as a pen for drawing. But we are interested in bringing the background you drew into the game. This is done very simply. Select the Canvas section as shown in the screenshot:

Output of the drawn level background as C code

Output of the drawn level background as C code

If you select the “C code” item, you get an array presented in the C language syntax (each element corresponds to the number of a tile from the tileset at its position; in the screenshot below, the “#” symbol has a number 0x23, i.e. if the background is filled with “lattices”, then the array will consist of numbers 0x23). But such a background representation will take up a lot of space (1024 bytes per screen, and we have only 32 bytes). Therefore, there is an item “C code with RLE” – this is the output of an array describing the background in compressed form using the RLE algorithm.

An example of a tileset from my game

An example of a tileset from my game

// Пример сжатого фона, представленного в виде массива
const unsigned char START_SCREEN[223]={
0x05,0x00,0x05,0x1f,0x01,0x03,0x05,0x1d,0x02,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,
0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,
0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x09,0x49,0x72,0x6f,0x6e,0x00,
0x53,0x74,0x65,0x61,0x6d,0x00,0x05,0x09,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,
0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x0a,0x43,0x6f,0x6e,0x74,
0x69,0x6e,0x75,0x65,0x00,0x67,0x61,0x6d,0x65,0x00,0x05,0x05,0x14,0x04,0x00,0x05,
0x1d,0x14,0x04,0x00,0x05,0x0a,0x4e,0x65,0x77,0x00,0x67,0x61,0x6d,0x65,0x00,0x05,
0x0a,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x0a,0x54,0x77,0x6f,0x00,0x70,
0x6c,0x61,0x79,0x65,0x72,0x73,0x00,0x05,0x07,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,
0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,
0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x0a,0x28,0x43,0x29,0x53,
0x77,0x61,0x6d,0x70,0x54,0x65,0x63,0x68,0x00,0x32,0x30,0x32,0x33,0x00,0x00,0x14,
0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,
0x00,0x05,0x1d,0x14,0x11,0x13,0x05,0x1d,0x12,0x00,0x05,0x1e,0x00,0x05,0x00
};

Using RLE requires the use of the decompression function. I didn’t reinvent the wheel and took a ready-made function for unpacking such arrays:

.importzp _joypad1, _joypad1old, _joypad2, _joypad2old,  _Frame_Count
.export _Get_Input, _Wait_Vblank, _UnRLE

.segment "ZEROPAGE"
RLE_LOW:	.res 1
RLE_HIGH:	.res 1
RLE_TAG:	.res 1
RLE_BYTE:	.res 1
.segment "CODE"
_UnRLE:
	tay
	stx <RLE_HIGH
	lda #0
	sta <RLE_LOW

	lda (RLE_LOW),y
	sta <RLE_TAG
	iny
	bne @1
	inc <RLE_HIGH
@1:
	lda (RLE_LOW),y
	iny
	bne @11
	inc <RLE_HIGH
@11:
	cmp <RLE_TAG
	beq @2
	sta $2007
	sta <RLE_BYTE
	bne @1
@2:
	lda (RLE_LOW),y
	beq @4
	iny
	bne @21
	inc <RLE_HIGH
@21:
	tax
	lda <RLE_BYTE
@3:
	sta $2007
	dex
	bne @3
	beq @1
@4:
	rts

Using this function looks very simple:

// Указываем начало таблицы имен, которую мы используем через регистры PPU
PPU_ADDRESS = 0x20;
PPU_ADDRESS = 0x00;
// В функцию распаковки передаем адрес начала массива с опсианием фона
UnRLE(START_SCREEN);

But don’t forget, to fill the entire screen with the background, you’ll have to turn off graphics. You can rewrite the unpacking function to fill the background line by line from an array, but I didn’t bother with this, since the entire background needs to be redrawn quite rarely. And I tried not to use ready-made backgrounds, but to generate them programmatically to save ROM.

Calling assembly functions from C code

Above I showed an example of calling an assembler function from C code. Let me describe this action in more detail using the example of the function of reading gamepad button presses:

_Get_Input:
; At the same time that we strobe bit 0, we initialize the ring counter
; so we're hitting two birds with one stone here
	lda _joypad1
	sta _joypad1old
    lda #$01
    ; While the strobe bit is set, buttons will be continuously reloaded.
    ; This means that reading from JOYPAD1 will only return the state of the
    ; first button: button A.
    sta $4016
    sta _joypad1
    lsr a        ; now A is 0
    ; By storing 0 into JOYPAD1, the strobe bit is cleared and the reloading stops.
    ; This allows all 8 buttons (newly reloaded) to be read from JOYPAD1.
    sta $4016
@loop:
    lda $4016
    lsr a	       ; bit 0 -> Carry
    rol _joypad1  ; Carry -> bit 0; bit 7 -> Carry
    bcc @loop
    rts

But since I write most of the code in C, I constantly have to call _Get_Input(). Linking C and ASM code is very simple (this is only relevant for the CC65 compiler). At the beginning of the asm file, the imported and exported names are written:

.importzp _joypad1, _joypad1old, _joypad2, _joypad2old,  _Frame_Count
.export _Get_Input, _Wait_Vblank, _UnRLE

.importzp shows the collector that we are accessing C variables located in the zero page of memory (zero page). This is what the declaration of such global variables looks like (local variables are not available to us, this causes a lot of unpleasant moments, but that’s a separate conversation):

#pragma bss-name(push, "ZEROPAGE")
volatile unsigned char joypad1;
volatile unsigned char joypad1old;
#pragma bss-name(pop) // End ZEROPAGE

Preprocessor Directives #pragma allow us to delimit areas of the cartridge’s memory, but as always, this is a big separate conversation. Those. To transfer the C variable to the asm code, we enter it after .importzp with the addition of the “_” character at the beginning.

And export works roughly the same way:

// Для использования асм-функций в си-коде достаточно объявить эти заголовки
// и их можно будет использовать как обычные си-функции
void __fastcall__ UnRLE(const unsigned char *data);
void __fastcall__ Get_Input(void);

Calling C functions in assembly code

Now let’s look at the opposite situation. Nothing complicated here either.

; Startup code for cc65/ca65
	; Импорт си-функций
	.import _main, _NMI
	; Экпорт переменных
	.export __STARTUP__: absolute = 1
	; Импорт переменных из Нулевой страницы (Zero Page)
	.importzp _Frame_Count

; Linker generated symbols
	.import __STACK_START__, __STACK_SIZE__
    .include "zeropage.inc"
	.import initlib, copydata
; Тут указываются функции обработчики прерываний
.segment "VECTORS"
    .word _NMI	;$fffa vblank nmi
    .word start	;$fffc reset
   	.word irq	;$fffe irq / brk

In the VECTORS segment, I access the NMI function, which is implemented in C. I showed it above. In the same way, it adds the symbol “_” during import and that’s it.

Useful features when developing NES games

There are many more interesting points I encountered during development, but I don’t want to go into an endless story. Therefore, let’s look at the implementation of several useful functions and leave it at that (and if the topic turns out to be in demand, we can write new materials about creating games for dandies).

One of the most important tasks when developing for older consoles is writing the most optimized code possible. Therefore, it is advisable to use assembler, and when using the C language, try to avoid unnecessary memory accesses. This is what one of the most used functions in my game looks like (Function for displaying text on the background):

/*Функция выводит строку до символа конца строки
PPU_ADDRESS - записываем сначала старший байт адрса, а затем младший
*p_text      - указатель на начало массива выводимой строки;
Пример использования:
    PPU_ADDRESS = 0x20; // Указываем адрес первого симовла в таблицу имен
    PPU_ADDRESS = 0x00;
    p_text       = TEXT2; // Берем адрес начала массива
    set_text ();
*/
void set_text(void) {
    while (*p_text) {
            PPU_DATA =  *p_text;
            ++p_text;
    }
}

This function does not use additional variables and is limited to a minimum of arithmetic operations. This is achieved due to the fact that when writing to PPU_DATA, the current address of access to video memory is automatically incremented.

Multiline text output:

/*Выводит многострочный текст в виде прямоугольного текста
Все строки массива строк должны иметь одинаковую длину, 
можно выравнивать длину пробелами
hight_byte - задает старший байт адреса вывода первого символа текста
low_byte; - младший байт адреса вывода первого символа
number_of_lines  - задает количество выводимых строк
Пример использования:
    hight_byte = 0x22;
	low_byte = 0xD2;
	p_text = LOCATION_TEXT; // Массив строк
	number_of_lines = 5;
	draw_multiline_text ();
*/
void draw_multiline_text (void) {
    for (i = 0; i < number_of_lines; ++i) {
        PPU_ADDRESS = hight_byte;
        PPU_ADDRESS = low_byte;
        // Выводим текущую строку до символа конца строки
        while (*p_text) {
            PPU_DATA =  *p_text;
            ++p_text;
        }
        // Пропускаем пустой символ
        ++p_text;
        // Отслеживаем переполнение младшего байта адреса PPU
        if (low_byte >= 0xE0)
            hight_byte += 0x01;
        // Переводим адрес вывода на новую строку
        low_byte += 0x20;
    }
}

The principle is approximately the same, but here it is taken into account that automatic increment is not enough to translate text to the next line. Those. each background line consists of 32 tiles and each tile has a two-byte address. For example, the zero tile of the zero row has the address 0x2000, and the last tile of the zero row has the address 0x201F. The zero tile of the first line has the address 0x2020, etc. Therefore, it is necessary to monitor for overflow of the low byte of the address for text output.

We haven’t figured out a bit about drawing the background, let’s finally look at how to display metasprites on the screen. This is also very simple.

// Массивы для отрисовки мехов
// Задает сдвиг спрайтов метатайла меха по оси Y
const unsigned char MetaSprite_Y[] = {0, 0, 
									  8, 8, 
									  16, 16}; 
// Хранит адреса спрайтов для отрисовки метаспрайта меха
const unsigned char MetaSprite_Mech[] = {
	0x00, 0x01, 0x10, 0x11, 0x20, 0x21, // UP direction
	0x02, 0x03, 0x12, 0x13, 0x22, 0x23, // DOWN direction
	0x04, 0x05, 0x14, 0x15, 0x24, 0x25, // RIGHT direction
	0x06, 0x07, 0x16, 0x17, 0x26, 0x27, // left direction
	// Состояние 2
	0x08, 0x09, 0x18, 0x19, 0x28, 0x29, // UP direction
	0x0A, 0x0B, 0x1A, 0x1B, 0x2A, 0x2B, // DOWN direction
	0x0C, 0x0D, 0x1C, 0x1D, 0x2C, 0x2D, // RIGHT direction
	0x0E, 0x0F, 0x1E, 0x1F, 0x2E, 0x2F  // left direction
};
// Младшие два битва определяют номер палитры
// 0b****_**00 - палитра 0
// 0b****_**01 - палитра 1
// 0b****_**10 - палитра 2
// 0b****_**11 - палитра 3
const unsigned char mech_attributes [][MECH_METASPRITE_SIZE] = {
	{0, 0, 0, 0, 0, 0},
	{0x01, 0x01, 0x01, 0x01, 0x01, 0x01}
};

// Задает сдвиг спрайтов метатайла по оси X
const unsigned char MetaSprite_X [] = {0, 8, 
									   0, 8,
									   0, 8}; //relative x coordinates
// Отрисовывает выбранного меха по координатам (X, Y)
// Координаты задаются в пикселях от 0 до 255
void draw_mech_to_x_y (void) {
    oam_counter = mech_shift_oam [index_selected_mech];
    // Считываем расцветку меха
    temp = p_selected_mech->color_;

    for (i = 0; i < MECH_METASPRITE_SIZE; ++i ) {
        // Первый байт задает положение спрайта по оси Y
        // + 4 - это для более красивого положения метаспрайта меха относительно клетки
        SPRITES[oam_counter] = MetaSprite_Y [i] + Y; 
        ++oam_counter;
        // Второй байт задает номер выбраного спрайта из .CHR
		SPRITES[oam_counter] = MetaSprite_Mech [i + p_selected_mech->direction_]; 
		++oam_counter;
        // Третий байт задает атрибуты спрайта (поворот, палитра)
		SPRITES[oam_counter] = mech_attributes [temp][i]; 
		++oam_counter;
        // Четвертый байт задает положение спрайта по оси X
		SPRITES[oam_counter] = MetaSprite_X [i] + X; // relative x + master x
		++oam_counter;
	}
}

The code shown above simply allows you to move a group of tiles as one whole (metasprite). You can always display 64 sprites of 8×8 pixels each at the same time, and no more than 8 sprites can be displayed in one line. The following sprites will be invisible. Moreover, sprites with lower indices have higher priority, i.e. if the sprites that have the minimum address are displayed first.

Each sprite is described in four bytes. Two bytes are responsible for the coordinates of the position of the upper left corner of the sprite, one byte is responsible for the attributes and the last byte stores the number of the output tile from the tileset (I showed above how to determine the tile number).

Conclusion

It was also possible to talk about the features of drawing pixel art for a dandy and ways to pick out sprites from ROM to other games and consoles (SNES, SEGA, etc.), but I decided to limit myself only to the technical component, since the article is still published on hub :).

I did not develop many points in my story enough or did not mention them at all, since the topic of the article is too vast to be covered in one post.

I apologize if the final text looks like a stream of consciousness (and it is a stream of consciousness). Because to be fair, it would be necessary to write a whole series of articles on game development for dandies from A to Z, but I first of all wanted to immerse the reader in the unusual world of retro game development and, perhaps, interest in the subject (there are actually enough ready-made series of articles on development on the Internet , but some subtle points that I tried to talk about are often not voiced). Therefore, this article is just a selection of small cases illustrating the depth of software development for old hardware.

It was also possible to highlight the underlying mechanics of the game, describe in detail its architecture (if you can call it that), the ENT of the game, etc., but in my opinion, this is not the content of the hub. That’s why I didn’t even mention the name of the game in the body of the article. Only at the end will I add a short video announcement of the game, where I talk about the game and show the gameplay (in case someone will still be interested in seeing the result of my modest labors in dynamics).

I’m really looking forward to your comments, I’m ready to answer all questions about the development and the game itself. This whole article was started, among other things, for the sake of receiving feedback, because without it there is nowhere.

PS: Among other things, there is an idea to post a template project (quick start project) on GitHub for creating games for dandies. I have such a project almost ready. Is it worth posting it on Github? I’m not ready to post the project of the game itself yet, it’s still too raw.

PS2: There are also some small efforts to create a first-person game in the same setting and lore for Dendy. The task is even more interesting in my opinion. I also look forward to your comments on this matter.

Thank you all for your attention.

Video version of the article

List of main resources that I used during development:

  • A series of articles on developing a game for dandy in C – https://habr.com/ru/articles/348022/

  • Wikipedia on development for the NES (everything is described there in great detail and with code examples) – https://www.nesdev.org/wiki/NES_reference_guide

  • Live forum on retro game development (and more) – emu-land.net

  • CC65 compiler website – https://www.cc65.org/

  • The emulator I use to debug the game is fceux.com

  • YY-CHR

  • NEXXT

  • A few more good articles about the console device (in Russian) – http://dendy.migera.ru/nes/g00.html

  • Project page. There you can download the ROM file for the emulator

  • Direct link to download the game – The Iron Steam 0.08

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *