User Tools

Site Tools


sd:writing_games_in_assembly_language

This is an old revision of the document!


Writing Games in Assembly Language

Introduction

At the end of SD-8516 Stellar BASIC we wrote a game called ROBOTS.BAS. This was a fun take on the classic CHASE or 'robots' game from the bsd games collection. Now, for our study of Assembly Language, let's write a similar game: 'robots.asm'. Don't worry if you're not familiar with the BASIC version of this program; everything will be explained here.

However, if this is your first time writing an assembly language program, you might want to familiarize yourself with the architecture of the SD-8516 first:

Onwards and upwards.

First Steps: Assembling and Running a Program

Just like BASIC programs have line numbers, or C programs have #include statements, an assembly language program should start with an .address statement. This will tell the assembler where to start assembling the program. This enables us to use labels like start: and message:. The assembler will calculate label addresses relative to this value while assembling the program.

      .address $000100
  
  start:
      LDELM @message
      LDAH $18
      INT $10
      RET
  
  message:
      .bytes "It works!", 13, 10, 0

If you enter this code into ed and save it as robots.asm, then you can do:

  as robots.asm robots

To run the program, just DLOAD (or LOAD) it and type “SYS 256”. This can be a bit confusing; why 256? Without instructions, people might not know what to do. There are three options.

  • 1. Tell people they have to type SYS and a number. Mysterious.
  • 2. use .address $030100 (the default SYS location). People can run it with SYS.
  • 3. Include a BASIC stub so people can type RUN.

For this program I will demonstrate option #3, although #2 is probably just as common. Our program now becomes:

    .address $000100

    ; BASIC stub: 10 SYS 269
    .bytes $FB, $0A, $00
    .bytes "SYS 269", $00
    .bytes $00, $00

; --- Entry point at $00010D (decimal 269) ---
start:
    LDELM @message
    LDAH $18
    INT $10
    RET

message:
    .bytes "It works!", 13, 10, 0

Using the editor

Enter this into ed by typing:

  f robots.asm
  a

Enter the program then type . on a line by itself. Then type w to save and q to quit. Now you can assemble the program.

Assembly

To assemble a program named robots.asm in d-tank, type:

  as robots.asm r

LOAD and RUN

Finally, enter

  dload r

After you dload r you can type LIST. It will show:

  10 SYS 269

Now you can RUN the program. This is a fun and convenient way to let users launch your assembly program.

Game 1: Robot

Our first game will be a version of robots called “Robot”, since there's only one robot.

Part 1. Program Structure

We begin any project by mapping out the structure of the program and talking about the subroutines, variables, and main ideas behind the code.

For robots we need:

  • A game loop
  • A subroutine that draws the map
  • A subroutine to get player input
  • A subroutine to move the robot
  • Collision detection

Initialization

Just like in the BASIC version, we can start by drawing the map. First, let's define our variables and then the map drawing function. We will include a simple main loop whose only job is to call draw_map once.

; Define variables.
; This section uses memory-location variables. $00 is memory location $00.
.equ PX $00        ; player x
.equ PY $01        ; player y
.equ RX $02        ; robot x
.equ RY $03        ; robot y

    .address $000100

    ; BASIC stub: 10 SYS 269
    .bytes $FB, $0A, $00
    .bytes "SYS 269", $00
    .bytes $00, $00

start:
    ; First, let's initialize our variables.
    LDAL #19
    STAL [@PX]         ; Store 19 as initial player X location.
    
    LDAL #11
    STAL [@PY]         ; Store 11 as player's initial Y
    
    LDAL #1
    STAL [@RX]
    STAL [@RY]         ; The robot starts at 1,1 for now,

main_loop:
    CALL @draw_map
    RET

Now that we have initialized our main variables and set up a program loop we are ready for the draw_map subroutine.

Part 2. Draw the Map

Like in the BASIC version, the draw_map subroutine will begin by clearing the screen. So the first two things we do are we replicate VSTOP and CLS from BASIC:

    .equ VIDEO_MODE $01EF00 ; This defines VIDEO_MODE as a memory location variable.

draw_map:
    ; VSTOP
    LDAL $81
    STAL [@VIDEO_MODE]
    
    ; CLS
    LDAH $10        ; CLS
    INT $10

In assembly, we can't easily call a BASIC command, so we do things slightly differently. Setting video mode to $81 sets us up in mode 1 but stops video updates (because bit 7 is set). So $81 means “mode 1, but set bit 7 so we don't update the screen”. This is normally done to stop screen tearing caused by drawing during an update. But here we're just doing it for academic reasons since assembly is so fast the screen won't have time to update until we're done.

Secondly, INT $10 AH=$10 is the clear screen function. We can call that as an interrupt helper here.

Drawing the border

To draw the border we need to set up a loop that draws stars on the top and bottom, left and right, just like in BASIC. Here we will examine the top and bottom loop:

    ; Draw top/bottom border: '*' at (0..39, 0) and (0..39, 24)
    LDBL #'*'       ; Load character into BL
    LDXL #0         ; set X location

dm_top_bottom:
    LDYL #0         ; set Y location
    LDAH $11        ; write char AL at XL, YL
    INT $10

This simply loads the character, sets the x and y locations, and calls write_char_at_xy via INT $10.

    LDYL #24
    LDAH $11        ; write char (on the bottom of the screen this time)
    INT $10
    INC XL
    CMP XL, #40
    JNC @dm_top_bottom

Here's the loop: After the top and bottom characters are drawn, we INC XL (which is the column marker). Then we CMP XL, #40. This means “check if it's under 40”– i.e. 0-39. If we are on 39, we need to draw again. If we just drew on column 39 and then we INC XL to 40, that means don't continue the loop, and we fall-through to the next function; drawing on the left and right sides.

    ; Draw left/right border: '*' at (0, 0..24) and (39, 0..24)
    LDBL #'*'
    LDYL #0
dm_left_right:
    LDXL #0
    LDAH $11        ; write char
    INT $10

    LDXL #39
    LDAH $11        ; write char
    INT $10

    INC YL
    CMP YL, #25
    JNC @dm_left_right

This code works just like the top and bottom but on the left and right sides. Instead of looping from 0 to 39 on XL, we will loop from 0 to 24 on YL. As before, we CMP to see if YL is 25. If it is, we fall through. A simple loop, in essence the exact same loop technique we used to draw the map in BASIC.

Draw the player and robots

    ; Draw player '@'
    LDBL #'@'
    LDXL [@PX]
    LDYL [@PY]
    LDAH $11        ; write char
    INT $10

    ; Draw robot 'r'
    LDBL #'r'
    LDXL [@RX]
    LDYL [@RY]
    LDAH $11        ; write char
    INT $10

    ; VSTART
    LDAL #1
    STAL [@VIDEO_MODE]
    
    RET

Finally we draw the player and the robot, and turn video updates back on (Set mode to 1, but without bit 7 set). As we are done drawing the map, we can now RET. If you save, assemble and run the program now you will see it draws the border, player and robot before exiting.

It's working!

Part 3: Player Input

Let's begin part 3 by adding a new subroutine to our main loop. Our main loop now becomes:

main_loop:
    CALL @draw_map
    CALL @get_input
    RET

get_input

The primary goal of this section is to get input from the player and deal with it. We can do this by using the getkey function:

get_input:
    LDAH $02        ; getkey()
    INT $10

In this case, the function specification for AH=$02, INT 0x10 is:

  ; ============================================================================
  ; AH=02h - Read Character (Blocking)
  ; Input:  None
  ; Output: AL  = ASCII character
  ;         B  = number of keys that were in buffer before this call
  ;         K  = keyboard flags at time of press
  ; Note:   X, Y preserved for cursor position
  ;         This function BLOCKS until a key is pressed
  ; ============================================================================

As we can see, this function returns the ASCII code of the key the player will press in AL. Thus, we can deal with the player's commands.

uppercase or lowercase?

The player might type an uppercase command or a lowercase command. Let's normalize the keys to uppercase before processing them:

    ; Uppercase: if AL >= 'a', it must be an uppercase character (or an invalid command).
    CMP AL, #97
    JNC @gi_check_keys      ; AL < 'a', skip
 
    CMP AL, #123
    JC @gi_check_keys       ; AL >= '{', skip
    
    SUB AL, #32
    
gi_check_keys:
    ...

CMP works as follows:

  CMP A, B        ; if A >= B, then set carry

Therefore,

  CMP AL, #97     ; if AL is greater or equal to #97 ('a') then set carry

So therefore if carry is NOT set, skip past the SUB command. Or else (if it's a lowercase ASCII) subtract 32 to convert from lowercase to uppercase.

The second compare has a protective function.

  CMP AL, #123             ; if AL >= 123, set carry.
  
  ; If it's '{' or anything after, skip:
  JC @gi_check_keys        ; Jump if Carry = Jump if AL > 'z' (al >= '}')

So after these two checks, the only values that reach SUB AL, #32 are exactly the bytes from 97 to 122 inclusive; i.e. 'a' to 'z'.

Visual summary:

AL Value Meaning What Happens
< 97 before a Skipped.
97-122 a to z SUB #32 becomes 'A' to 'Z'.
>= 123 after 'z'
'}' and up
Skipped.

Processing input

We will use the HJKL convention:

gi_check_keys:
    CMP AL, #'H'
    JZ @move_left

    CMP AL, #'J'
    JZ @move_down

    CMP AL, #'K'
    JZ @move_up

    CMP AL, #'L'
    JZ @move_right

    CMP AL, #'Q'
    JZ @quit_game

    RET

This is easy enough; after a CMP, if the values are equal then the zero flag is set. We will use JZ (jump if zero flag is set) to go to the correct routine.

Moving the Player

move_left:
    LDAL [@PX]        ; load variable PX into register AL.
    DEC AL            ; X = X - 1 (move to the left!)
    
    CMP AL, #1        ; bounds check. Is the player on the border?
    JC @ml_ok         ; AL >= 1, so it's ok. skip to ml_ok,
    
    LDAL #1           ; If  we reach here the player was on the border; set position to 1.
    
ml_ok:
    STAL [@PX]        ; pass! Save the player's location.
    RET

This is the move_left command. We begin by loading the player's X location into AL and executing the move. After a bounds check, we save the variable. All of the other moves operate this way; we attempt to move the player and then check for bounds:

move_down:
    LDAL [@PY]
    INC AL
    CMP AL, #23
    JNC @md_ok              ; AL < 23 (i.e. <= 22), keep
    LDAL #23
md_ok:
    STAL [@PY]
    RET

move_up:
    LDAL [@PY]
    DEC AL
    CMP AL, #1
    JC @mu_ok               ; AL >= 1, keep
    LDAL #1
mu_ok:
    STAL [@PY]
    RET

move_right:
    LDAL [@PX]
    INC AL
    CMP AL, #39
    JNC @mr_ok              ; AL < 39 (i.e. <= 38), keep
    LDAL #38
mr_ok:
    STAL [@PX]
    RET

Quit Command

The player can also quit the game by pressing q:

quit_game:
    ; CLS
    LDAH $10
    INT $10

    ; Print "QUIT GAME"
    LDBLX @msg_quit
    LDAH $66
    INT $05
    LDAH $64
    INT $05

    RET

msg_quit:
    .bytes "QUIT GAME", 0

Moving the Robot

Moving the robot is similar to the player, except that we do not have commands from the robot (it has no keyboard to type), so we must simply try to move it towards the player.

We will begin by determining if the player is to the left or the right, and then move left or right towards the player. Then we will check if the player is above or below us and then move up or down towards the player.

move_robot:
    ; X axis: move RX toward PX
    LDAL [@RX]
    LDBL [@PX]
    CMP AL, BL
    JZ @mr_check_y          ; RX == PX, skip
    
    ;; If carry is set, then AL is bigger than BL and we should decrease RX's position.
    JC @mr_x_dec            ; RX >= PX (but not equal), so RX > PX


    ; fall-through RX < PX
    ; In this case since the RX is less than PX, we should increase it to move towards the player.
    INC AL
    JMP @mr_x_done
mr_x_dec:
    DEC AL
mr_x_done:
    STAL [@RX]              ; save the robot's X position for draw_map.

mr_check_y:
    ; Y axis: move RY toward PY
    LDAL [@RY]
    LDBL [@PY]
    CMP AL, BL
    JZ @mr_done             ; RY == PY, skip
    
    JC @mr_y_dec            ; RY > PY
    
    ; RY < PY
    INC AL
    JMP @mr_y_done
    
mr_y_dec:
    DEC AL
    
mr_y_done:
    STAL [@RY]              ; save the robot's y position for draw_map.

mr_done:
    RET

After checking the relative location of the player vs. the robot, the robot attempts to move towards the player.

After this, you can update the main loop:

main_loop:
    CALL @draw_map
    CALL @get_input
    CALL @move_robot

    RET

Part 4. Win condition

The game is over when the robots collide with you. Here's the new main loop:

main_loop:
    CALL @draw_map
    CALL @get_input
    CALL @move_robot

    ; Check collision: PX==RX && PY==RY
    ; The collision check falls through to game_over only if both axes match, otherwise loops back.
    LDAL [@PX]
    LDBL [@RX]
    CMP AL, BL
    JNZ @main_loop
    LDAL [@PY]
    LDBL [@RY]
    CMP AL, BL
    JNZ @main_loop
    JMP @game_over

    RET

That little bit at the end checks for collision. As an exercise to the reader, you can try to move it into a subroutine.

Next let's write the game-over ending:

game_over:
    ; CLS
    LDAH $10
    INT $10

    ; Print "OH NO! THE ROBOT CATCHES YOU."
    LDBLX @msg_game_over1
    LDAH $66
    INT $05
    LDAH $64                ; newline
    INT $05

    ; Print "GAME OVER."
    LDBLX @msg_game_over2
    LDAH $66
    INT $05
    LDAH $64
    INT $05

    RET

; ============================================================================
; String data
; ============================================================================
msg_game_over1:
    .bytes "OH NO! THE ROBOT CATCHES YOU.", 0
msg_game_over2:
    .bytes "GAME OVER.", 0
msg_quit:
    .bytes "QUIT GAME", 0

At this time, your robots.asm has the same functionality as BASIC's ROBOTS.BAS. In fact it's a little bit cleaner; instead of having to press ESC to quit the BASIC version, you have cleanly implemented a quit command.

Part 5. Music and Sound

Let's add a little sound effect to the game (just like in BASIC!)

robot_beep:
    LDELM @robot_sfx
    LDAH $52
    INT $11
    RET

robot_sfx:
    .bytes "T120 W1 V5 O2 L24 B", 0

Now add CALL @robot_beep right after CALL @move_robot in the main loop:

main_loop:
    CALL @draw_map
    CALL @get_input
    CALL @move_robot
    CALL @robot_beep
    
    ...

There, now you can PLAY music strings just like in BASIC. This is good!

robots.asm Complete Listing

// robots.asm

; Variables
.equ PX $00    ; player X
.equ PY $01    ; player Y
.equ RX $02    ; robot X
.equ RY $03    ; robot Y

.equ VIDEO_MODE           $01EF00   ; Current video mode (1 byte)

    ; Address statement.
    .address $000100

    ; BASIC stub: 10 SYS 269
    .bytes $FB, $0A, $00
    .bytes "SYS 269", $00
    .bytes $00, $00

; --- Entry point at $00010D (decimal 269) ---
start:
    ; Initialize variables
    LDAL #19
    STAL [@PX]
    LDAL #11
    STAL [@PY]
    LDAL #1
    STAL [@RX]
    STAL [@RY]

main_loop:
    CALL @draw_map
    CALL @get_input
    CALL @move_robot
    CALL @robot_beep

    ; Check collision: PX==RX && PY==RY
    ; The collision check falls through to game_over only if both axes match, otherwise loops back.
    LDAL [@PX]
    LDBL [@RX]
    CMP AL, BL
    JNZ @main_loop
    LDAL [@PY]
    LDBL [@RY]
    CMP AL, BL
    JNZ @main_loop
    JMP @game_over

    RET

;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Draw the map.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;
draw_map:
    ; VSTOP
    LDAL $81
    STAL [@VIDEO_MODE]

    ; CLS
    LDAH $10        ; CLS
    INT $10

    ; Draw top/bottom border: '*' at (0..39, 0) and (0..39, 24)
    LDBL #'*'
    LDXL #0

dm_top_bottom:
    LDYL #0
    LDAH $11        ; write char AL at XL, YL
    INT $10

    LDYL #24
    LDAH $11        ; write char
    INT $10
    INC XL
    CMP XL, #40
    JNC @dm_top_bottom

    ; Draw left/right border: '*' at (0, 0..24) and (39, 0..24)
    LDBL #'*'
    LDYL #0
dm_left_right:
    LDXL #0
    LDAH $11        ; write char
    INT $10

    LDXL #39
    LDAH $11        ; write char
    INT $10

    INC YL
    CMP YL, #25
    JNC @dm_left_right

    ; Draw player '@'
    LDBL #'@'
    LDXL [@PX]
    LDYL [@PY]
    LDAH $11        ; write char
    INT $10

    ; Draw robot 'r'
    LDBL #'r'
    LDXL [@RX]
    LDYL [@RY]
    LDAH $11        ; write char
    INT $10

    ; VSTART
    LDAL #1
    STAL [@VIDEO_MODE]

    RET

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Get input from the player.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
get_input:
    ; Blocking GETKEY -> AL
    LDAH $02
    INT $10

    ; Uppercase: if AL >= 'a' and AL < 'z'+1, subtract 32
    CMP AL, #97
    JNC @gi_check_keys      ; AL < 'a', skip
    CMP AL, #123
    JC @gi_check_keys       ; AL >= '{', skip
    SUB AL, #32

gi_check_keys:
    CMP AL, #'H'
    JZ @move_left
    CMP AL, #'J'
    JZ @move_down
    CMP AL, #'K'
    JZ @move_up
    CMP AL, #'L'
    JZ @move_right
    CMP AL, #'Q'
    JZ @quit_game

    RET

move_left:
    LDAL [@PX]
    DEC AL
    CMP AL, #1
    JC @ml_ok               ; AL >= 1, keep
    LDAL #1
ml_ok:
    STAL [@PX]
    RET

move_down:
    LDAL [@PY]
    INC AL
    CMP AL, #23
    JNC @md_ok              ; AL < 23 (i.e. <= 22), keep
    LDAL #23
md_ok:
    STAL [@PY]
    RET

move_up:
    LDAL [@PY]
    DEC AL
    CMP AL, #1
    JC @mu_ok               ; AL >= 1, keep
    LDAL #1
mu_ok:
    STAL [@PY]
    RET

move_right:
    LDAL [@PX]
    INC AL
    CMP AL, #39
    JNC @mr_ok              ; AL < 39 (i.e. <= 38), keep
    LDAL #38
mr_ok:
    STAL [@PX]
    RET

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Move the robot.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
move_robot:
    ; X axis: move RX toward PX
    LDAL [@RX]
    LDBL [@PX]
    CMP AL, BL
    JZ @mr_check_y          ; RX == PX, skip
    JC @mr_x_dec            ; RX >= PX (but not equal), so RX > PX
    ; RX < PX
    INC AL
    JMP @mr_x_done
mr_x_dec:
    DEC AL
mr_x_done:
    STAL [@RX]

mr_check_y:
    ; Y axis: move RY toward PY
    LDAL [@RY]
    LDBL [@PY]
    CMP AL, BL
    JZ @mr_done             ; RY == PY, skip
    JC @mr_y_dec            ; RY > PY
    ; RY < PY
    INC AL
    JMP @mr_y_done
mr_y_dec:
    DEC AL
mr_y_done:
    STAL [@RY]

mr_done:
    RET

;;;;;;;;;;;;;;;;;;;;;;;;
;; Game Over.
;;;;;;;;;;;;;;;;;;;;;;;;
game_over:
    ; CLS
    LDAH $10
    INT $10

    ; Print "OH NO! THE ROBOT CATCHES YOU."
    LDBLX @msg_game_over1
    LDAH $66
    INT $05
    LDAH $64                ; newline
    INT $05

    ; Print "GAME OVER."
    LDBLX @msg_game_over2
    LDAH $66
    INT $05
    LDAH $64
    INT $05

    RET

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Player quit game
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
quit_game:
    ; CLS
    LDAH $10
    INT $10

    ; Print "QUIT GAME"
    LDBLX @msg_quit
    LDAH $66
    INT $05
    LDAH $64
    INT $05

    RET

robot_beep:
    LDELM @robot_sfx
    LDAH $52
    INT $11
    RET

; ============================================================================
; String data
; ============================================================================
msg_game_over1:
    .bytes "OH NO! THE ROBOT CATCHES YOU.", 0
msg_game_over2:
    .bytes "GAME OVER.", 0
msg_quit:
    .bytes "QUIT GAME", 0
robot_sfx:
    .bytes "T120 W1 V5 O2 L24 B", 0

Game 2: Rogueima

Game programming is not that difficult, as long as you establish the scope of your game and understand all the individual parts, it can be considered a robotic, mechanical exercise of coding. Even if there are some parts you don't understand, you can learn-as-you-go so long as you have a conception of what it should be like.

But it's important not to bite off more than you can chew. Let's continue on our journey of learning Assembly for the SD-8516 by moving from Robot towards Rogue.

Scope and Planning

As always, the first step is scope and planning. What exactly are we trying to accomplish with this game? And, why Assembly, why not BASIC? The simple answer is that in a typical roguelike game, although it is character based, it requires data structures and computations that are difficult to perform in BASIC. In it's current form. Stellar BASIC is not set up to really make a big, professional-quality game. That will change in the future as it gets improved over time, but for now, if you want to make something 'big' like a clone of one of the great classic games, you are probably better off with Assembly. Let's hope I can get a C compiler up someday and that might change for good.

Not that there's anything wrong with assembly, but it takes twice as long to really get anything done. It ends up twice as fast and half the size, though, so, it's not all bad!

Ok, here's what we need.

  • Everything in the robot.asm game
  • A status display showing our character information
  • A map that we can explore
  • Some form of 'fog of war' or 'lighting' – not required, but nice.
  • The ability for monsters to randomly appear
  • The ability to fight and defeat monsters
  • The ability to pick things up and use them
  • The ability to view an inventory
  • The ability to save your game

Once we have all these things, the game will be considered finished. Beyond the above, there is actually quite a lot of work that would need to be done to make a game like NetHack (or even, like bsdgames' rogue), but that's okay. Baby steps.

Step 1. Drawing the Screen

The easiest first step is to draw a status bar. In classic rogue, the status bar is on the bottom of the screen. But this means the map is now 80 x 23 (or 80×22 on some displays) characters, and that is incredibly unbalanced. Let's try a different approach and put the status on the right. This is how Temple of Apshai did it, not to mention Ultima 4, and many other games. The Bard's Tale series, technically, is an example of the other style; having the status on the bottom.

Second, let's use Mode 6 for this game, since it is the classic terminal interface you would expect from a game like Rogue or NetHack.

  ; **************************************
  ; *                          *         *
  ; *                          *         *
  ; *                          *         *
  ; *                          ***********
  ; *                          *         *
  ; *                          *         *
  ; *                          *         *
  ; *                          *         *
  ; *                          *         *
  ; **************************************

The above is not to scale, but is the idea. The left side will hold the map, and the right side will hold the status. Status, for now, will include Name, Strength, and Hit Points. That's all we need to start. Therefore we will need new variables to hold Name, Strength, and Hit Points, and we will need to write that into the status bar after we draw the map border.

To the front of the code we will add variable space for the player's stats:

  .equ PX  $00    ; player X
  .equ PY  $01    ; player Y
  .equ RX  $02    ; robot X
  .equ RY  $03    ; robot Y
  .equ player_name    $04     ; max 16 characters.
  .equ pname_zero     $15     ; zero-terminated
  .equ player_hp      $16     ; max HP 255, 0=dead.
  .equ player_str     $17     ; one byte to hold strength

An interesting question is what things like “strength” and “hit points” mean. We could just as well call them “hearts” or “health” or “attack power” or “skill”. We could use the old D&D idea of 3d6 (3 to 18) but maybe for a different game. Here we just set strength and health to 10, and worry what that means in game terms later on.

Therefore, our initialization becomes:

  init_game:
      LDA #10
      STAL [@player_str]
      STAL [@player_hp]

You may notice we defined the variables differently here. Above, we added a .bytes imperative to reserve space. The label above the .bytes becomes the label of the memory address where those bytes are assembled. Instead, we could have defined the variables like this:

  .equ player_str    $00       ; zero page address 0
  .equ player_hp     $01       ; zero page address 1
  .equ player_name   $02       ; 16 bytes + 1 zero starting at $02
  .equ pname_zero    $12       ; this must always be a zero even if there are earlier zeroes.
  .equ free_space    $13       ; free space starts at $13

Next, when drawing the map, we will use the following modified routine:

draw_map:
    ; VSTOP
    LDAL $86                  ; Mode 6 VSTOP
    STAL [@VIDEO_MODE]

    ; CLS
    LDAH $10        ; CLS
    INT $10

    ; Draw top/bottom border: '*' at (0..79, 0) and (0..79, 24)
    LDBL #'*'
    LDXL #0

dm_top_bottom:
    LDYL #0
    LDAH $11        ; write char AL at XL, YL
    INT $10

    LDYL #24
    LDAH $11        ; write char
    INT $10

    CMP XL, #59      ; if XL >= 10, then set carry.
    JNC @dm_skip_ms    ; So if it's not, then don't draw the middle separator char.

    LDYL #10        ; status/text window separator
    LDAH $11        ; write char
    INT $10

dm_skip_ms:
    INC XL
    CMP XL, #80     ; Mode 6 is 80 chars wide.
    JNC @dm_top_bottom

    ; Draw left/right borders: '*' at (0, 0..24) and (59, 0..24) and (79, 0..24)
    LDBL #'*'
    LDYL #0
dm_left_right:
    LDXL #0         ; left border
    LDAH $11        ; write char
    INT $10

    LDXL #59        ; middle border
    LDAH $11        ; write char
    INT $10

    LDXL #79        ; right side border
    LDAH $11        ; write char
    INT $10


    INC YL
    CMP YL, #25
    JNC @dm_left_right

    ; Draw player '@'
    LDBL #'@'
    LDXL [@PX]
    LDYL [@PY]
    LDAH $11        ; write char
    INT $10

    ; Draw robot 'r'
    LDBL #'r'
    LDXL [@RX]
    LDYL [@RY]
    LDAH $11        ; write char
    INT $10

    ; VSTART
    LDAL #6               ; Turn off VSTOP
    STAL [@VIDEO_MODE]

    RET

The essential changes are, we section off the map differently, and we VSTOP/VSTART based on Mode 6.

Because we're using Mode 6, we need to initialize into Mode 6. Start becomes:

start:
    ; Set mode 6.
    LDAH $40    ; Set video mode
    LDAL #6     ; to 6
    INT 0x10

    CALL @init_game
   
    JMP @main_loop    ; start game

init_game:
    ; Initialize variables
    LDAL #19
    STAL [@PX]
    LDAL #11
    STAL [@PY]
    LDAL #1
    STAL [@RX]
    STAL [@RY]

    LDAL #10
    STAL [@player_str]
    STAL [@player_hp]
    
    CALL @get_player_name
    
    RET

Next, the game won't be any fun unless we can name our hero. Let's ask the player their name. To do this, we will need an INPUT function. Let's use IO_INPUT:

  ; ----------------------------------------------------------------------------
  ; AH=$68: IO_INPUT - Read line of input from user
  ; Input:  None
  ; Output: ELM = pointer to input string (null-terminated, in @PATB_TBUF)
  ;         B = numeric value (parsed via atoi)
  ;         CF = 0 on success, 1 on empty input
  ; Notes:  Reads characters until ENTER (13).
  ;         Supports backspace for editing.
  ;         Echoes characters to screen.
  ;         ENTER is not echoed.
  ;         Max input length limited by PATB_TBUF size.
  ; ----------------------------------------------------------------------------

get_player_name:
    LDELM @what_is_your_name
    LDAH $10      ; write string
    INT 0x10
    
    LDAH $68      ; IO_INPUT
    INT 0x05
    ; player name is now at ELM.

    ; Ensure player name is no longer than 16 characters.
    MOV FLD, ELM
    ADD FLD, #17
    LDAL #0
    STAL [FLD]

    ; Copy player name into variable space.
    LDFLD @player_name
name_copy_loop:
    LDAL [ELM, +]
    STAL [FLD, +]
    JNZ @name_copy_loop

    RET ;; return.

what_is_your_name:
    .bytes "What is your name? ", 0

Drawing the Status Area

When a function gets too big I like to break it up into smaller functions, that way it's easier to think about the program. It seems draw_map is getting too big, so I will break it into draw_border, draw_player and draw_mobs. Then we will add 'draw_world', 'draw_stats' and 'draw_msg'. These individual functions will all be called by draw_map. This makes it easier to co-ordinate things.

Secondly, let's move all these functions into a file called draw.asm, and the main file can be changed from robots.asm to rogueima.asm. You can keep an old copy of robot.asm if you want, but we aren't going to use it anymore.

What is a “mob” you ask? A mob is a “mobile object”. In the game, each tile is represented by a letter; monsters, as well as walls, doors, items and the player, will all be represented by characters. This means a moving object like a monster (or the player) is the same kind of thing as a wall or a sword; an object in the game.

So let's get all the drawing functions done.

draw_map:
    CALL @draw_borders
    CALL @draw_world
    CALL @draw_player
    CALL @draw_mobs
    CALL @draw_stats
    CALL @draw_msgs
    RET

draw_borders:
    ; Put the old draw_map code in here.
    RET

draw_player:
    ; Put the old draw-player code in here.
    RET

draw_mobs:
    ; put the old draw robot code in here.
    
draw_stats:
    ; We will work on this next.
    RET

draw_msgs:
    ; We will work on this after.
    RET

draw_world:
    ; We will work on this last.
    RET

Our plan of attack is to write draw stats, then msgs, then world. For each one, we will need to define some sort of variable to hold the data. For STATUS, we already have everything we need; name, strength, and health. We'll throw in “steps” and “score”. You can add the steps and score variables yourself, or we will include them in the intermission program listing shortly.

To write at a specific locatoin, we will need to use the set_cursor routine:

  ; ============================================================================
  ; INT 0x10, AH=15h - Set Cursor Position
  ; Input:  Y = row (0-based)
  ;         X = column (0-based)
  ; Output: Clamps X and Y to valid range.
  ; Notes:  Works with current video mode (reads VIDEO_ROWS, VIDEO_COLUMNS)
  ; ============================================================================

We will also use INT 0x05 IO_PUTNUM, to draw numbers up to 65,535:

  ; ----------------------------------------------------------------------------
  ; INT 0x05, AH=$63: IO_PUTNUM - Output number to screen
  ; Input:  B = number to output (16-bit, signed)
  ;         AL = 0 unsigned, 1 signed (we will let the caller decide)
  ; Output: Number written to screen as ASCII digits
  ; ----------------------------------------------------------------------------

draw_stats:
    ; Set cursor
    LDA $1500
    LDX #61
    LDY #2
    INT 0x10

    LDELM @str_name  ; Draw 'Name: '
    LDA $1800        ; write string
    INT 0x10

    ; Draw player's name.
    ; This will draw right after the 'Name: ' above.
    ; That's why we added spaces to the strings (below).
    LDELM @player_name
    LDA $1800   ; write string
    INT 0x10

;;;;;;;;;;;;;;;;;;;; Strength
    LDA $1500        ; set cursor
    LDX #61
    LDY #4
    INT 0x10

    LDELM @str_str     ; Draw 'Str: '
    LDA $1800          ; write string
    INT 0x10

    ; Draw player's strength score
    LDA $6300          ; print unsigned word (will print after string above).
    LDB [@player_str]
    INT 0x10

;;;;;;;;;;;;;;;;;;;; Health
    LDA $1500          ; set cursor
    LDX #61
    LDY #5
    INT 0x10

    LDELM @str_hp      ; Draw 'Health: '
    LDA $1800          ; Write string
    INT 0x10

    LDA $6300          ; Write number (unsigned)
    LDB [@player_hp]
    INT 0x05


;;;;;;;;;;;;;;;;;;;; Score
    LDA $1500          ; Set cursor
    LDX #61
    LDY #7
    INT 0x10

    LDELM @str_score   ; Draw 'Score: '
    LDA $1800          ; write string
    INT 0x10

    LDA $6300          ; print number
    LDB [@player_score]
    INT 0x05


;;;;;;;;;;;;;;;;;;;; Time
    LDA $1500          ; set cursor
    LDX #61
    LDY #8
    INT 0x10

    LDELM @str_time    ; Draw 'Time: '
    LDA $1800          ; write string
    INT 0x10

    LDA $6300          ; print number
    LDB [@game_time]
    INT 0x05

    RET

str_name:
    .bytes "Name: ", 0

str_str:
    .bytes "STR: ", 0

str_hp:
    .bytes "HP: ", 0

str_time:
    .bytes "T: ",0

str_score:
    .bytes "Score: ", 0

Finally, there's one more little change we need to make. Remember that the old robot game was 40×25? Let's head over to the get_input: function and just change where the player is able to move.

move_right:
    LDAL [@PX]
    INC AL
    CMP AL, #59
    JNC @mr_ok              ; AL < 59 (since we're in mode 6 for this game).
    LDAL #58
mr_ok:
    STAL [@PX]
    RET

MVP 1: Base game

Now that we have status drawing nicely, let's remove the draw_robot function. We will keep the stub, but we won't draw the robot or check for collision. You can also remove move_robots and change it to move_mobs (add a stub; you can make all these changes to your own copy). What's left is a simple game where you can move the player around. But nothing interesting happens, so let's increment game time every tick; add a CALL @inc_game_time (or similar) to the game loop (at the end):

inc_game_time:
    LDA [@game_time]
    INC A
    STA [@game_time]
    RET

We'll also add a way for the player to quit out, since we changed the game loop. Change quit_game to this:

quit_game:
    ; CLS
    LDAH $10
    INT $10

    ; Print "QUIT GAME"
    LDBLX @msg_quit
    LDAH $66
    INT $05

    POP ELM ; destroy return address of CALL from main loop
    RET     ; exit program.

We now have, essentially, a 'game'; the longer you play it, the higher your score. The top score is 65,535 – after that, it rolls over.

Variables

To proceed from this point we need to talk about variables and data structres. First we will talk a bit about NVAR, and why it's no good here. Then we will introduce structs and INSQUE.

NVAR stands for named variable system. It is the system BASIC uses to store variables. It stores data starting at $FFFF and grows downwards towards program space. This lets you efficiently use the space in bank 0 to separate code and data. Note that there are no memory protections here; it's up to you not to accidentally over-write your code or data. To do this, check PATB_VARTOP ($01EEE4; 3 bytes). This points to the top of variable storage. If you want to reserve 4k of memory for variables for example, don't add more variables if PATB_VARTOP falls below $F000 ($F000 to $FFFF is 4k). For our purposes we will ignore this, since our code and data is not expected to be more than a few kilobytes. We should have plenty of room to spare.

So, Using NVAR is very cumbersome in assembly because it requires the use of strings to set and get variable names and data.

Should you use NVAR?

In a previous version of this tutorial, I wrote several examples that outlined how to do parallel arrays in assembly language using NVAR. It took hundreds of lines of code and was very clunky. After writing the section on structs and INSQUE, I realized that the code had no value, not even for educational purposes. NVAR is just not useful for assembly programming because it only understands strings, even when storing integers.

To be honest I don't even know if the old code worked. It was getting huge and was nowhere near finished. Ultimately, there's nothing “wrong” with NVAR, but it's designed to be a part of Stellar BASIC, and not to be used as a general purpose variable storage library. For assembly, we should focus on structs and INSQUE instead.

Game Objects using Structures

Structures are C's struct done by hand. They are fixed (or variable length) data structures, hence the name. Structures are easy because they are mechanical and quick to program, but they are also difficult because you need to re-write the CRUD (create-read-update-destroy) functions for every structure. This is where programming in a language like C makes things faster. Yet, a little work never hurt anyone, and at runtime, nothing is faster than a fixed width assembly struct.

Let's dive right in. First, we define the structure itself, which is the location of the data in the record. What we'll do here is just define every kind of thing we need to know about an object:

    ; Object structure
.equ OBJ_ID     0        ; 2 bytes, max 64k objects
.equ OBJ_TYPE   2        ; 2 bytes; ex. 0 = nothing, 1 = wall, 2=gold, etc.
.equ OBJ_X      4        ; 2 bytes; x position on map
.equ OBJ_Y      6        ; 2 bytes; x position on map
.equ OBJ_GLYPH  8        ; 1 byte; what it looks like (char)
.equ OBJ_VIS    9        ; 1 byte; what it looks like on the map (char)
.equ OBJ_NAME   10       ; 16 bytes + 1 zero (16 max len str)
.equ OBJ_DATA1  27       ; 2 bytes (data 1, ex. gold value)
.equ OBJ_DATA2  29       ; 2 bytes (data 1, ex. gold value)
.equ OBJ_LEN    31       ; record length for an OBJ.

This gives us everything we need to know to access an object once we find it.

While we're at it, we can temporarily use the same structure for monster data. Health and Strength can be kept in DATA1 and level/other can be kept in DATA2.

Since we are combining objects and monsters in one structure (this is where the term MOB or mobile object comes from), we can calculate how many monsters/objects we will be able to have in the game. If we use Bank 3 for this data, we have 64k, enough to hold over 2,000 objects at 31 bytes per record.

CRUD

For any struct we need Create, Read, Update and Destroy accessor functions. However, we will not design functions like that just yet; instead we will look at an example first, to understand the concept.

Example Create

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Create object
;; 
;; Returns the ID of a free object in A.
;; A = 0 if no free space.
;;
;;
.equ OBJ_STRUCT_BASE   $030000 ; memory location of data structure
.equ OBJ_STRUCT_END    $03FFFF ; End of valid memory

create_obj:
    PUSH X
    PUSH FLD

    ; Variable set-up: 3 register variables
    :   X: stores current record ID
    ; ELM: Address of current record
    : FLD: stores last valid memory address for an object
    LDX #0               ; object ID counter
    LDELM @OBJ_STRUCT_BASE  ; object data base
    LDFLD @OBJ_STRUCT_END
    SUB FLD, @OBJ_LEN

create_obj_loop:
    ADD ELM, @OBJ_TYPE   ; Point to object type
    LDA [ELM]            ; Get object type
    SUB ELM, @OBJ_TYPE   ; Return to start-of-struct
    CMP A, #0            ; Is it an unused object (TYPE=0)?
    JZ @create_obj_found ; Yes, we found an empty object.

    INC X                ; Point ID to next record
    ADD ELM, @OBJ_LEN    ; Point to next record's memory address
    CMP ELM, FLD         ; Do we have space to write a record here?
    JC @create_obj_not_found  ; Yes. That means we didn't find a free ID.
    
    JMP @create_obj_loop ; Try again with new ELM/ID no.

create_obj_found:
    ; Return free ID in A
    ; ELM already points to the free object address.
    MOV A, X
    POP FLD
    POP X
    RET

create_obj_not_found:
    MOV A, #0    ; 0 = no free space
    POP FLD
    POP X
    RET

This code is very simple. It walks the struct and if it finds an object with no type, it returns it's ID in A and the address of the free struct memory in ELM.

Here is an example of what the initialization subroutine might look like:

;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Init object structure
;; input/output: none
;;
;; Clears all objects and ensures their ID is set.
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;
init_struct_obj:
    PUSH A
    PUSH X
    PUSH ELM
    PUSH FLD
    
    ; Variable set-up: 4 register variables
    ; ELM: Address of Record
    ; A: stores default type (will be 0)
    : X: stores current record ID
    : FLD: stores last valid memory address for an object
    LDELM @OBJ_STRUCT_BASE  ; object data base
    LDA #0                  ; type will be set to 0
    LDX #0                  ; object ID counter
    LDFLD @OBJ_STRUCT_END   ; load end of memory
    SUB FLD, @OBJ_LEN       ; reserve space for at least one object

init_struct_obj_loop:
    ADD ELM, @OBJ_ID     ; point to ID
    STX [ELM]            ; Store ID
    SUB ELM, @OBJ_ID     ; return to start-of-record
    
    ADD ELM, @OBJ_TYPE   ; point to TYPE
    STA [ELM]            ; Store TYPE 
    SUB ELM, @OBJ_TYPE   ; return to start-of-record
    
    INC X                ; Point ID to next record
    ADD ELM, @OBJ_LEN    ; Point to next record's memory address

    ; check if next record is valid
    CMP ELM, FLD         ; Does this record have enough space (is it valid?)
    JNC @init_struct_obj_loop    ; Yes, set it up (ELM >= FLD = set carry).
   
    ; finished initializing.
    POP FLD
    POP ELM
    POP X
    POP A
    RET

NVAR or Struct?

Each approach has benefits and drawbacks. The NVAR system is useful for dynamic allocation. This can be a huge win. It's an obvious choice for string-based hash tables. But since you have to write your own accessors to use NVAR anyways, you might as well use structs. A custom struct will always be smaller and faster than using NVAR.

But there is another big reason to use structs; it allows you to make lists with INSQUE.

INSQUE, REMQUE, SCANQUE

INSQUE deals with linked lists. It is a simple enough concept; each node has a forward link and a backward link, and the head is a data-less sentinel node at the start of variable memory. As it turns out, this makes programming data structures very easy, so we will use this final method for our game.

To start, we must create a HEAD node. This is just a six byte sentinel node that points to itself:

  LDBLX $A000
  LDELM $A000
  STBLX [ELM, +]   ; Set FLINK to point to itself.
  STBLX [ELM, +]   ; Set BLINK to point to itself.
  $A000: 00 0A 00 00 0A 00 <user data follows>

Setting FLINK (forward link) and BLINK (backward link) to the same address creates the HEAD node. The data at +6 is considered user data. When used with a struct this adds six bytes to the total struct. We now define our struct like this:

; Object structure (offset includes 6 byte INSQUE header)
.equ OBJ_FLINK  0        ; forward-link (managed by INSQUE)
.equ OBJ_BLINK  3        ; back-link (managed by INSQUE)
.equ OBJ_ID     6        ; 2 bytes, max 64k objects
.equ OBJ_TYPE   8        ; 2 bytes; ex. 0 = nothing, 1 = wall, 2=gold, etc.
.equ OBJ_X      10       ; 2 bytes; x position on map
.equ OBJ_Y      12       ; 2 bytes; x position on map
.equ OBJ_GLYPH  14       ; 1 byte; what it looks like (char)
.equ OBJ_VIS    15       ; 1 byte; what it looks like on the map (char)
.equ OBJ_NAME   16       ; 16 bytes + 1 zero (16 max len str)
.equ OBJ_DATA1  33       ; 2 bytes (data 1, ex. gold value)
.equ OBJ_DATA2  35       ; 2 bytes (data 2, reserved)

.equ OBJS_BASE  $030000  ; objects data starts here
.equ OBJS_END   $03FFFF  ; use all of bank 3 (over 1,700 objects at 37 bytes/obj.)
.equ OBJ_LEN    37       ; record length for an object record including 6 byte header

The original 31 byte STRUCT is now 37 bytes in total length. What do we get for using INSQUE instead of a flat structure? There are many useful advantages.

Adding and Deleting nodes

You can easily add a new node anywhere in the list without moving memory by calling INSQUE to insert a new node after an existing node. Inserting after the head node inserts at the front of the list:

  .equ HEAD_NODE $A000
  .equ HEAD_BLINK $A003
  
  ; Insert as first node (after head node).
  INSQUE ELM, @HEAD_NODE     ; Insert after head node
  ; Append as last node (after last node)
  LDBLX @HEAD_NODE
  LDBLX [BLX+3]    ; get HEAD.BLINK (last node)
  INSQUE ELM, BLX  ; Insert after last node (append to list)

INSQUE modifies the HEAD node and any other nodes it needs to in order to link all the nodes together in a circular list. These records now form a linked list which you can traverse by looking at the first six bytes of each node.

  Byte 0: 24 bit pointer to next node
  Byte 3: 24 bit pointer to previous node
  Byte 6: data

To delete such a node,

  REMQUE ELM             ; Where ELM points to any node.

This will delete the node at ELM by unlinking it from the list; the node at it's FLINK will be connected to the node at it's BLINK. The data isn't changed, but it will no longer appear in the list's chain of links.

Sorting a list

You can easily sort a list by unlinking an 'B' object in ELM and adding it after an 'A' object with:

  REMQUE ELM      ; unlink node A (at ELM) from the list.
  INSQUE ELM, FLD ; insert node A (at ELM) after B (at FLD).

Memory Management

INSQUE offers several advantages in terms of memory management. In any struct-based system system you can traverse the list of objects and check for some field that indicates the object is unused. Such as id=0 or an alive flag. But with INSQUE you can use SCANQUE to find this space very quickly.

  LDA $0000                ; Object ID, for example
  SCANQUE ELM, A, @OBJ_ID  ; Find first $0000 at index OBJ_ID

Pointing to the start of the list is just LDELM [@OBJS_BASE]. This loads the address of the first object on the list.

Initialization is just like a flat struct; point to the field you need to configure to show an object is empty and set that field for every object. Then finding free space is a matter of looking for an object which is not in use.

init_objects:
    ; Write blank HEAD node at @OBJS_BASE (this clears the whole list)
    LDELM @OBJS_BASE        ; ELM = node ptr
    LDFLD @OBJS_BASE        ; FLD = FLINK/BLINK ptr
    STELM [FLD, +]          ; write FLINK and advance to BLINK
    STELM [FLD]             ; write BLINK (and leave FLD pointed at HEAD.BLINK)

    ADD ELM, #6             ; advance past head to first data node space.

    LDGLK @OBJS_END
    SUB GLK, @OBJ_LEN
    INC GLK                 ; GLK now equals first invalid address

init_obj_loop:
    ; 1. test if there's enough memory for another object
    CMP GLK, ELM            ; Will we overflow memory if we initialize an object here?
    JNC @init_obj_done      ; Yes, so exit (ELM >= GLK == CF).

    ; 2. initialize this object space
    LDI @OBJ_ID
    STB [ELM + I]           ; Write id = 0 for this record
    INSQUE ELM, [FLD]       ; FLD is a ptr to HEAD.BLINK; Append to list

    ; 3. loop
    ADD ELM, @OBJ_LEN       ; point to next object
    JMP @init_obj_loop      ; loop

init_obj_done:
    LDELM [@OBJS_BASE]      ; Load HEAD.FLINK (Point to first object on list)
    RET

This is just 17 lines of code; vastly smaller than the NVAR init, get and set code – and those functions didn't have any memory overflow checks.

If your records are not of equal length (or if you just prefer to unlink them when you remove them) you can perform garbage collection by copying each record to a new space, using INSQUE to create new records. Then either change the OBJ_BASE pointer to the new object data, or copy the objects back to the original location.

Representing Items and Monsters in the Game World

Adding objects and monsters to the map becomes easy. We simply traverse the list of all objects and display anything with an X and a Y coordinate.

Here is the new create_gold function using structs and INSQUE:

create_gold:
    ; Find a free space in the object list
    LDA #0
    LDELM [@OBJS_BASE]  ; start scanning at HEAD.FLINK (i.e. the first object)
    LDI @OBJ_ID
    SCANQUE ELM, A, I
    JNZ @create_gold_oom

    ; Since we're using a sentinel node, if the sentinel is a match it means there was no valid node.
    CMP ELM, @OBJS_BASE
    JZ @create_gold_oom

    LDA [@obj_ids]
    LDI @OBJ_ID
    STA [ELM+I]
    INC A
    STA [@obj_ids]

    LDA #1              ; obj type 1 = gold
    LDI @OBJ_TYPE
    STA [ELM+I]

    ;; Now that we'e saved type, lets get its X, Y and amount data.
    LDAH $00     ; 16 bit random number
    INT 0x13     ; math servics
    MOD B, #100
    INC B       ; amount is now 1 to 100.
    LDI @OBJ_DATA1
    STB [ELM+I]

    LDAH $00     ; 16 bit random number
    INT 0x13
    MOD B, #58
    INC B        ; random number 1 to 58
    LDI @OBJ_X
    STB [ELM+I]

    LDAH $00     ; 16 bit random number
    INT 0x13
    MOD B, #23
    INC B        ; random number 1-23
    LDI @OBJ_Y
    STB [ELM+I]

    ; Add GLYPH and VIS.
    LDAL #'$'
    LDI @OBJ_GLYPH
    STAL [ELM+I]
    LDI @OBJ_VIS
    STAL [ELM+I]

    CLC         ; no error
    RET         ; return

create_gold_oom:
    SEC     ; set carry on error
    RET

Putting an object on the map becomes a simple matter of adding it to the list of objects.

Testing item generation

Let's test our new item and object system by adding create_gold as a command (bound to g). We'll also add arrow key movement.

gi_check_keys:
    CMP AL, #'H'
    JZ @move_left
    CMP AL, #'J'
    JZ @move_down
    CMP AL, #'K'
    JZ @move_up
    CMP AL, #'L'
    JZ @move_right

    CMP AL, #128
    JZ @move_left
    CMP AL, #129
    JZ @move_down
    CMP AL, #130
    JZ @move_up
    CMP AL, #131
    JZ @move_right

    CMP AL, #'G'
    JZ @create_gold
    CMP AL, #'Q'
    JZ @quit_game

    RET

In addition, add a call in draw_map:

  ...
  CALL @draw_world
  CALL @draw_items    ; <-- add this
  CALL @draw_player
  ...

Now, we need to create the draw_items function. All it does is loop through every item, find it's X, Y and glyph, and draw it:

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Draw all the items that are placed on the map.
;;
draw_items:
    LDELM @OBJS_BASE       ; Start at first object

draw_items_loop:
    LDELM [ELM]            ; Traverse to next object
    CMP ELM, @OBJS_BASE
    JZ @draw_items_done    ; End loop if we returned to HEAD node.

    LDI @OBJ_ID
    LDA [ELM+I]      ; get object ID.
    JZ @draw_items_loop    ; Skip non-initialized/empty objects

    ; Does this item have an X and Y position?
    LDI @OBJ_X
    LDX [ELM+I]
    JZ @draw_items_loop
    LDI @OBJ_Y
    LDY [ELM+I]
    JZ @draw_items_loop

    ; Get the VIS (visible glyph) of the object.
    LDI @OBJ_VIS
    LDBL [ELM+I]

    LDAH $11        ; write char BL at XL, YL
    INT $10
    JMP @draw_items_loop ; Next!

draw_items_done:
    RET

Finally, add a call to init_items at the end of init_game in rogueima.sda:

init_game:
    
    ...
    
    LDA #0
    STA [@game_time]
    STA [@player_score]

    CALL @init_objects      ; Initialize the game objects list.

    RET

With all this, pressing g will create piles of gold which appear on the map!

Picking up gold

It makes sense that the player is able to pick up gold. First, let's detect if there is any object on the square the player is standing on. If there is, we can print a small message.

First let's add a pick up command to the command list:

gi_check_keys:

    ....
    
        CMP AL, #'G'
    JZ @create_gold
    CMP AL, #'Q'
    JZ @quit_game
    CMP AL, @','        // pick things up using comma
    JZ @pick_up_items

    RET

Next, let's add gold that the player picks up to his inventory and remove such gold from the objects list.

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Pick up items
;; Picks up the first pile of gold where the player is standing.
;;
pick_up_items:
    LDELM @OBJS_BASE         ; Start at first object

pick_up_items_loop:
    LDELM [ELM]              ; Traverse to next object
    CMP ELM, @OBJS_BASE
    JZ @pick_up_items_done   ; End loop if we returned to HEAD node.

    LDI @OBJ_ID
    LDA [ELM+I]              ; get object ID.
    JZ @pick_up_items_loop   ; Skip non-initialized/empty objects

    ; Does this item match the player's location?
    LDI @OBJ_X
    LDX [ELM+I]              ; object x

    LDIL [@PX]               ; player x
    CMP IL, XL
    JNZ @pick_up_items_loop  ; No match, keep looking.

    LDI @OBJ_Y
    LDY [ELM+I]              ; objecy y

    LDJL [@PY]               ; player y
    CMP JL, YL
    JNZ @pick_up_items_loop  ; No match, keep looking.

    ; Oh, there's an item here!
    LDI @OBJ_TYPE
    LDA [ELM+I]
    CMP A, #1                ; is it gold?
    JNZ @pick_up_items_loop  ; No, keep looking.

    ; How much gold is it?
    LDI @OBJ_DATA1
    LDA [ELM+I]
    LDB [@player_score]
    ADD B, A                 ; Add one point for every gold.
    STB [@player_score]      ; Save score.

    LDA #0
    LDI @OBJ_ID              ; Remove the gold object from the game
    STA [ELM+I]              ; by zeroing it's ID (thats all!)

pick_up_items_done:
    RET

Now you can have fun running around picking up treasure. We have already come so far!

more to come – under construction

The World Map

Now let's work on displaying a world map. To display a world map, we will need three important things:

  1. A source of truth for the world map
  2. A projection of that map onto the play area
  3. Game logic that provides map interaction

The first and most important thing we need is the map itself. Here is our first world map:

map1_id:
    .bytes 1
map1_name:
    .bytes "START   ", 0       ; 8 characters and zero terminated
map1_up:
    .bytes 5, 5
map1_down:
    .bytes 25, 15
map1_dim:
    .bytes 80, 25
map1_data:
    .bytes "################################################################################" ; 0
    .bytes "#         #                                                                    #" ; 1
    .bytes "#         #                                                                    #" ; 2
    .bytes "#         #                                                                    #" ; 3
    .bytes "#         #                                                                    #" ; 4
    .bytes "#    @    +                                                                    #" ; 5
    .bytes "#         #                                                                    #" ; 6
    .bytes "#         #                                                                    #" ; 7
    .bytes "#         #                                                                    #" ; 8
    .bytes "#         #                                                                    #" ; 9
    .bytes "#####+#####                                                                    #" ; 10
    .bytes "#                                                                              #" ; 11
    .bytes "#                                                                              #" ; 12
    .bytes "#                                                                              #" ; 13
    .bytes "#                                                                              #" ; 14
    .bytes "#                        >                                                     #" ; 15
    .bytes "#                                                                              #" ; 16
    .bytes "#                                                                              #" ; 17
    .bytes "#                                                                              #" ; 18
    .bytes "#                                                                              #" ; 19
    .bytes "#                                                                              #" ; 20
    .bytes "#                                                                              #" ; 21
    .bytes "#                                                                              #" ; 22
    .bytes "#                                                                              #" ; 23
    .bytes "#                                                                              #" ; 24
    .bytes "#                                                                              #" ; 25
    .bytes "#                                                                              #" ; 26
    .bytes "#                                                                              #" ; 27
    .bytes "#                                                                              #" ; 28
    .bytes "#                                                                              #" ; 29
    .bytes "#                                                                              #" ; 30
    .bytes "#                                                                              #" ; 31
    .bytes "#                                                                              #" ; 32
    .bytes "#                                                                              #" ; 33
    .bytes "#                                                                              #" ; 34
    .bytes "#                                                                              #" ; 35
    .bytes "#                                                                              #" ; 36
    .bytes "#                                                                              #" ; 37
    .bytes "#                                                                              #" ; 38
    .bytes "################################################################################" ; 39

This map was constructed to be very simple and allow us to test:

  • Having a map in the first place (it works!)
  • Reading and displaying the map
  • Map interaction (Opening and closing doors, walls blocking movement and vision)

(Note, the @ and > are added for visual reference. They will be ignored by the game).

Data Structures

It's worth taking a moment to pause here and look, really look, at the data struct-ures we have so far and think carefully about how we want to proceed. Ideally what we want is an easy pointer from where we stand to a list of items. That's easy to do in a flat structure or with an insque. Let's say we want to have a map as data. Each square takes up space. A 58×23 map that fits on the screen will take 41,354 bytes if we try to store each tile as an object – barely manageable. But a 256×256 world map would take more memory than the system has available. How did games like Ultima 4, 5 and 6 have such large world maps? There can be a few answers.

First, for Ultima 6, it was intended for computers with more memory. If you stored the Ultima 6 world map as an array of bytes, it would be 1 megabyte in size. The dungeon areas were just as large. Ultima 4 and 5 had a 256×256 world map – just large enough to be byte-addressable. But since it couldn't fit in memory how did they do it? They pared it down and removed things from the game. We could probably do a better port of Ultima 6, but Ultima 6 is not going to be a realistic goal with under 200k usable memory for graphisc and data.

One technique is swapping to disk. In the case of a world map, using map chunks. The map was chopped up into areas and/or read in small chunks. We can do this with one map file and FSEEK. Chunks can be stored as rows and read in all at once at certain well-known positions of the file. For example, every 256 bytes can be considered one 16×16 chunk. This lets us FSEEK to the chunk and read it very quickly during play.

Another technique is one we have mentioned before. If the PLAY window has 11×11 tiles, we can store the entire displayable map in under 4k. If we store the 9 regions around the player, reloading when he changes regions (chunks), this will take just over half a bank of memory.If we merely preload the chunks he is likely to enter next (such as the three chunks next to the zone he is in, we can do it in less than 15k.

  • a. If the player is in the top third of the chunk, load the chunk north of him into memory.
  • b. If the player is in the right third of the chunk, load the chunk east of him into memory.
  • c. If A and B is true, as a convenience, load the chunk northeast of him into memory.

This logic can be repeated for all the chunks around the current one. The result is a seamless experience traversing a world too large to fit in memory all at once.

more to come

sd/writing_games_in_assembly_language.1777879577.txt.gz · Last modified: by appledog

Donate Powered by PHP Valid HTML5 Valid CSS Driven by DokuWiki