Table of Contents
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 defaultSYSlocation). People can run it withSYS. - 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.asmgame - 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.
- Source code so far: Rogueima I MVP 1
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:
- A source of truth for the world map
- A projection of that map onto the play area
- 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? The answer is that they didn't load it into memory all at once. They kept it on disk or on the cartridge (or CD) and read only the parts they needed to display.
If the PLAY window has 11×11 tiles, we can store the map and nine nearby tile regions in just 1089 bytes.
A roguelike game has a few more tiles, however. A normal roguelike has just one screen's worth of data – no more than 80×25 squares. If we keep an array of INSQUE HEAD nodes and objects, this will amount to 86,000 bytes of data. If we can get this down to 32,000, maybe – just maybe – the system can be usable.
The first thing we can do is remove 4 bytes for the X and Y position of an object. The X and Y position is now to be derived from it's location in the grid structure.
Every object needs an ID, a TYPE, a GLYPH and a VIS. But not DATA1 or DATA2; and 17 bytes for a name is wasteful. Lets remove these 21 bytes and include 3 bytes for a DATA address; these 3 bytes are the data address of the first (data bearing) node in a list of data such as items contained inside this object, the object's string name, and so forth. This removes 21 bytes per object, since most objects will have a default name based on their type, but over-rides can be scanned from it's DATA list.
.equ TILE_ID 0 ; Tile ID .equ TILE_TYPE 0 ; 2 bytes; ex. 0 = nothing, 1 = wall, 2=gold, etc. .equ TILE_GLYPH 2 ; 1 byte; what it looks like (char) .equ TILE_VIS 3 ; 1 byte; what it looks like on the map (char) .equ OBJ_SIZE 7 ; 7 bytes
This short struct tells us what kind of tile is on any square, and then it has a poitner (it's ID) that can help us find any items that belong on this tile.
more to come
entire displayable map in memory 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
