C64 Basic 地下城探险:哥布林袭击(C64 Basic 第 8 部分)
C64 Basic Dungeon Crawler: Goblin Attack (C64 Basic Part 8)

原始链接: https://retrogamecoders.com/c64-basic-dungeon-part8/

本篇 C64 开发教程的第八部分重点介绍了如何为地牢探险游戏增加挑战性与易用性。 **关键更新:** * **自定义控制:** 游戏现支持用户自定义按键映射,通过将按键存储在变量中而非硬编码,避免了 Commodore BASIC 中常见的变量名冲突问题。 * **动态敌人:** 地精现在通过并行数组(`GX`、`GY`、`GH`)进行追踪,并具备简单的“智能”,可以追逐玩家。 * **战斗系统:** 近战现采用基于 d20 的系统,结合力量修正值和护甲等级(AC)来判定命中与伤害。 * **内存管理:** 项目遇到了严重的“内存损坏”错误,即游戏的数组覆盖了自定义字符集。解决方案包括缩减字符集大小,并手动重新指向 VIC-II 视频芯片及 BASIC 内存上限(使用 `POKE` 指令保护内存区域)。 本教程强调了手动管理 8 位共享内存资源的重要性。代码现已可在 RGC 在线 IDE 中进行实时编辑,后续计划改进战斗系统、增加胜负判定及“战争迷雾”功能。

Hacker News 新帖 | 过往 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 C64 Basic 地牢探险游戏:哥布林突袭(C64 Basic 第 8 部分)(retrogamecoders.com) 65 分,由 ibobev 发布于 13 小时前 | 隐藏 | 过往 | 收藏 | 4 条评论 帮助 ido 9 小时前 | 下一条 [-] 好主意,但视频里不断插入的表情包片段太让人分心了。 回复 YeGoblynQueenne 6 小时前 | 上一条 | 下一条 [-] 行吧,又是那种屠杀手无寸铁小哥布林的游戏。停止对绿皮生物的种族灭绝! 回复 hosel 6 小时前 | 父评论 | 下一条 [-] 把哥布林同情者抓起来! 回复 YeGoblynQueenne 3 小时前 | 根评论 | 父评论 | 下一条 [-] 哈!抓到一只守序善良阵营的冒险者! 回复 考虑申请 YC 2026 年秋季批次吧!申请截止日期为 7 月 27 日。 准则 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

In part seven the dungeon was full of goblins that were about as threatening as garden gnomes. They stayed exactly where the map generator dropped them. You could walk up, poke them, take five points of damage and stroll away, and they’d still be standing there in the same spot when you came back.

This time they chase you. A small change in code but big step forward in feel, and getting there dragged me into a weird and nasty memory bug that corrupted my character set while I watched. The bug is probably the most useful lesson in this whole part, so if you skim, don’t miss the memory section.

Open the full program and edit it in your browser here: part8d.bas in the RGC online IDE.

Where we got to last time

Quick recap of where part seven left off. We had a custom character set loading from disk, a map built out of three by three meta-tiles which randomly generated on every run, a player rolled up with proper Dungeons and Dragons style stats, collision detection that read the screen back with PEEK, and a teleport key for when the random map boxed you into a corner.

What it didn’t have was a game. Nothing threatened you, so there were no stakes.

Four things change in this part:

  • You can redefine the keys, because QAOP is not to everybody’s taste.
  • Goblins get a position, a health value, and a brain (albeit, a very small one).
  • Combat happens with a real d20 roll and a strength modifier.
  • And the game stops eating itself, which is a longer story than it sounds.

You can now follow the tutorials and edit the code right in your web browser with the Online Retro IDE

– No downloads, configuration, etc necessary, and it is free!

Choose your own keys

Somebody called me out on the controls, and for good reason. QAOP was one of the standards growing up so it’s burned into my fingers, but if you didn’t grow up with it, it’s a weird cluster of keys. I could have switched to WASD, but that annoys a different set of people just as much. The answer is that both sides are right, and the fix is to stop picking for them.

The trick to making this work is that the game must never compare against a literal key again. Instead of asking “did they press Q“, it should ask “did they press whatever is currently stored as the up key“. So first we need to store the defaults:

REM DEFAULT KEYS
QKEY$="Q"
AKEY$="A"
OKEY$="O"
PKEY$="P"
FKEY$="SPACE"

Those variable names look like they were chosen carelessly. They weren’t, and this is one of the common traps of Commodore BASIC for modern coders.

Only the first two letters actually matter

Commodore BASIC stores a variable name in exactly two bytes. You can type PLAYERHEALTH and it will happily accept it, but internally it only ever takes note of PL, so the other ten characters are read and thrown in the bin. Which means PLAYERHEALTH and PLAYERX are the same variable, which makes your game do something baffling and difficult to debug that has nothing to do with the logic you wrote.

That means my five key variables are deliberately unique in their first two characters: QK, AK, OK, PK, FK.

There’s a second, sneakier version of the same trap, and part seven’s code has a comment about it that people asked me to explain:

REM GL NOT GOLD - GO CLASHES WITH GOTO (2-LETTER NAMES)
GL=0

A variable name shouldn’t really contain a BASIC keyword anywhere inside it because BASIC tokenisers can get very unhappy when you do. The C64 doesn’t read your original code listing, it first needs to be tokenised, scanning left to right and replacing every keyword recognised with a token byte.

It doesn’t know or care that you meant GOLD as a name. It sees GO, which is a keyword ( GO TO with a space), swaps it for a token, and leaves you with a token followed by LD. If it tries to run that it will throwsa syntax error at you from a line that looks perfectly OK when viewed on screen.

The classic ones that get people are TO, IF, ON, OR and AND. So SCORE contains OR, COUNT (ON), TOTAL (TO). If a program refuses to work and you can’t see why, look at your variable names before you hack away at your logic.

Reading a key that you can’t predict

With the defaults in variables, the input routine changes from testing letters to testing variables:

IF P$=OKEY$ THEN PX=PX-1
IF P$=PKEY$ THEN PX=PX+1
IF P$=QKEY$ THEN PY=PY-1
IF P$=AKEY$ THEN PY=PY+1
IF P$=FKEY$ THEN GOSUB FIRE

Now the redefine screen just has to write new values into those five variables. The interesting constraint is that it has to use GET, not INPUT. INPUT waits for you to type something and press RETURN, it echoes what you type, and it has strong opinions about commas.

Someone remapping their controls might want to press a key that INPUT would freak out with or mangle, for example someone using an emulator might want to use the modern cursor keys. Asking them to press their chosen key and then hit RETURN is a sure way to get the wrong result, besides it’s one keypress too many for something that should feel immediate.

GET reads a single character from the keyboard buffer and returns immediately, whether or not anything is there. That “whether or not” needs to be tested. If the buffer is empty and you get an empty string, the program carries straight on, so GET on its own is not “wait for a key“, it’s “check for a key“. To make it wait, you must loop it until it gives you something useful:

CHANGETHISKEY:
GET K$: IF K$="" THEN GOTO CHANGETHISKEY
RETURN

Everything else is just asking the questions in order and recording the answers:

CHANGEKEYS:
PRINT "{CLR}{GREY}"
PRINT "CHANGE KEYS"
PRINT "==========="
PRINT ""
PRINT "DEFAULT: QAOP AND SPACE"
PRINT "PRESS THE KEY FOR UP"
GOSUB CHANGETHISKEY
QKEY$=K$
PRINT "PRESS THE KEY FOR DOWN"
GOSUB CHANGETHISKEY
AKEY$=K$
PRINT "PRESS THE KEY FOR LEFT"
GOSUB CHANGETHISKEY
OKEY$=K$
PRINT "PRESS THE KEY FOR RIGHT"
GOSUB CHANGETHISKEY
PKEY$=K$
PRINT "PRESS THE KEY FOR FIRE"
GOSUB CHANGETHISKEY
FKEY$=K$
PRINT "HAPPY WITH YOUR CHOICES? (Y/N)"
HOLDFORKEY:
GET A$
IF A$="" THEN GOTO HOLDFORKEY
IF A$="Y" THEN RETURN
IF A$="N" THEN GOTO CHANGEKEYS
RETURN

The confirmation at the end matters more than it looks. It’s very easy to fat-finger the third key of five, and without a way out you’d be stuck with a control scheme you didn’t really choose. Answering N jumps back to the label at the top and starts the whole thing again, which is about the easiest undo you’ll ever write.

One note if you’re typing this into a real machine rather than the IDE: those labels (CHANGEKEYS, CHANGETHISKEY) are a convenience of the RGC online IDE, which resolves them to line numbers when it tokenises. On a stock C64 they’d be GOSUB 4000 and friends. Same code, but uglier to read IMO. Download and open the .PRG if you want to see the line numbers in all their glory!

Goblins need to be tracked before they can move

Here’s something I glossed over in the video. Up until now, the goblins didn’t really exist as far as the program was concerned. They were drawn, sure, and by checking RAM the collision code could tell it had bumped into screen code 38, but the program had no way to track them, no idea how many there were, and no way to refer to any specific one.

That’s fine when a goblin is decoration, but the moment it needs to move, we need to know which one moved, from where, to where, and whether it’s still alive. So here the goblins get promoted from screen pixels to actual game objects:

REM DECLARE ENEMY VARIABLES
GC=0: REM GOBBO COUNT
DIM GH(10) : REM GOBBO HEALTH ARRAY
FOR GC=1 TO 10: GH(GC)=10: NEXT GC
GC=0
DIM GX(10) : DIM GY(10) : REM GOBBO LIST

Three parallel arrays: GX and GY hold each goblin’s column and row, GH holds its health, and the variable GC counts how many we’ve actually placed. Goblin number 3 is GX(3), GY(3), GH(3).

Parallel arrays are how you represent a record or a struct in a language that has no such thing, and on an 8-bit machine they’re faster than the alternative anyway.

The FOR GC=1 TO 10 loop is quietly reusing GC as its counter to fill every health slot with 10, then resetting it to zero. It saves a variable, and every variable you declare in BASIC costs you bytes plus a slot to search past on every single lookup. Small, this is the sort of thing that adds up.

Our goblins get added to the list right where they get drawn in the meta-tile routine. If the tile we’re about to stamp down is number 17, that’s the tile with a goblin in the middle:

DRAWMT:
PRINT MT$(MT,0);"{DOWN}{LEFT}{LEFT}{LEFT}";
PRINT MT$(MT,1);"{DOWN}{LEFT}{LEFT}{LEFT}";
PRINT MT$(MT,2);
REM IF MT=17 THEN IT CONTAINS A GOBBO
IF MT=17 THEN GOSUB ADDGOBBO
RETURN
ADDGOBBO:
REM ADD TO THE GOBBO LIST - GX=COL (X), GY=ROW (Y), MATCHES PX/PY
REM GUARD: STOP AT 10 SO GC NEVER EXCEEDS DIM GX(10) = ?BAD SUBSCRIPT
IF GC>=10 THEN RETURN
GC=GC+1
GX(GC)=COL+1
GY(GC)=ROW+1
RETURN

The +1 on each coordinate is the bit worth noting. ROW and COL are where the metatile starts, its top left corner, but the goblin isn’t at the top left, it’s in the middle of the three by three block. So the goblin’s real screen position is COL+1, ROW+1. Get this wrong and your goblins are all standing in a wall, one square up and to the left of where you can see them.

Ten metatile 17s could theoretically come up on a random map, so once GC hits 11 you’re writing to GX(11) in an array you dimensioned to 10, and BASIC stops your game dead with ?BAD SUBSCRIPT ERROR. Proper, fair enemy generation is on the list to do but for now this stops a crash.

Dumb enemy ‘AI’ that works

The game is turn based, which means the player moves, then the enemies move, then we check whether the game is over.

GAMELOOP:
OX=PX : OY=PY
REM PLAYER INPUT AND DRAWING
GOSUB KEYS
IF PXOX OR PYOY THEN GOSUB ERASEPLAYER
GOSUB DRAWPLAYER
REM HERE IS WHERE YOU PUT THE ENEMY LOGIC
GOSUB ENEMYLOGIC
REM CHECK GAME OVER CONDITIONS HERE
REM --------------------------------
REM RETURN TO GAMELOOP
GOTO GAMELOOP

Turn based offers some flexibility because the enemies only get to think after you press a key. There’s no timing, and no danger of the goblins running away with the CPU while you’re deciding what to do next. On a machine this slow, taking turns is the thing that makes enemy AI in BASIC viable.

The simplest enemy that is actually a threat is one that always moves towards you. It doesn’t calculate a path around walls, just walks in your direction each turn:

ENEMYLOGIC:
IF GC=0 THEN RETURN
FOR G=1 TO GC
REM SKIP DEAD GOBBOS (CBM BASIC CANT SKIP FORWARDS)
IF GH(G)PX THEN IX=GX(G)-1
IF GX(G)PY THEN IY=GY(G)-1
IF GY(G)

Read the four IF lines as a sentence and the whole “AI” is exposed. If the goblin’s column is bigger than yours it needs to go left, so knock one off. If it’s smaller, add one. Same for the rows. That’s it. Because both axes are tested independently, a goblin that’s up and to the left of you moves diagonally down and right in a single turn, which looks far more deliberate than the four comparisons appear.

The proposed move goes into IX and IY rather than straight into GX(G) and GY(G), and that separation is important because we don’t know yet if the move is possible, and we still need a copy of the old position. So IX and IY are “where it wants to go“, GX(G) and GY(G) stay “where it currently is“, and only MOVEENEMY is allowed to change one to the other.

The collision check is again a single PEEK. If the target cell contains screen code 46, aka a floor dot, it’s empty and the goblin can move to it. Anything else, such as a wall, an item, another goblin, you, and the condition fails and the goblin simply stands still for a turn. That one comparison eliminates the need for a whole complex navigation system, but it also means goblins pile up in doorways and get stuck behind walls like the keystone cops.

MOVEENEMY:
REM SET CURSOR POSITION TO OLD GOBBO CELL AND ERASE
GOSUB ERASEENEMY
REM SET TO NEW CELL AND DRAW
GX(G)=IX : GY(G)=IY
GOSUB DRAWENEMY
RETURN
ERASEENEMY:
ROW=GY(G) : COL=GX(G) : GOSUB CURSORSET : PRINT "{GREY}."
RETURN
DRAWENEMY:
ROW=IY : COL=IX : GOSUB CURSORSET : PRINT "{GREEN}&";
RETURN

The order in MOVEENEMY is intentional. Erase first, using the old GX(G)/GY(G), then overwrite them with the new position, and only then draw afresh. It’s the classic software sprite problem, you’re responsible for cleaning up your own mess, and you can only clean it up while you still remember where it was.

Why there’s a label in the middle of a FOR loop

That NEXTENEMY: label sitting just above NEXT G is there because Commodore BASIC has no CONTINUE to bail out of the FOR NEXT. There is nothing that means “I’m done here, move on with the next Goblin“.

What it does have is the much-loved GOTO. Jumping forward to a label immediately before the NEXT means the counter still increments, the loop still knows what it’s doing. We just skipped code. It’s a CONTINUE built out of the only option available to us.

You must jump to inside the loop, not out of it. If you GOTO out of a FOR and never reach the NEXT, the loop’s entry sticks around on the stack forever, do that enough and you get ?OUT OF MEMORY ERROR. Not a fun error message to debug when your program isn’t using much memory.

Roll for Combat

Combat is initiated from two directions. The goblin starts a fight if it ends its move next to you:

ABS strips the sign off a number, so ABS(GX(G)-PX) is “how many columns apart are we“. Both distances being 1 or less describes the eight squares surrounding you plus the one you’re standing on, so this is a neat one-line way of asking “are we in melee range“, diagonals included, without eight separate IF statement comparisons.

Also, of course, you can start a fight by colliding with a Gobbo on purpose:

REM GOBLINS HURT HP
REM - COMBAT WILL ERASE THE GOBBO WHEN THE GOBBO DIES
IF CH=38 THEN G=0:GOSUB COMBAT : RETURN

Compare that against part seven’s version, which was a flat HP=HP-5 and nothing else. Now it’s an actual fight. The G=0 is a marker meaning “we got here by walking into something, not by iterating the goblin list“, which combat uses to know it doesn’t have a specific goblin index to work with (yet).

Now, the actual D&D tabletop version of this is a lot of arithmetic. There’s a reason Pathfinder was often jokingly referred to as Mathfinder.

Real D&D checks which stat applies to what you’re doing (strength for melee, dexterity for a bow, intelligence or wisdom depending on your flavour of magic), derives a modifier, rolls a D20, adds the modifier, compares against the target’s Armour Class, then rolls damage with its own dice and its own modifiers. I’m not doing all of that here. I want it working first, and we can always come back and layer more complexity if we want to:

COMBAT:
REM ROLL D20
R=RND(-TI)
D20=INT(RND(1)*20)+1
REM ADD MODIFIER FROM STRENGTH
MOD=INT((STATS(0)-10)/2)
D20=D20+MOD
REM CHECK IF HIT GOBBO AC AND DETERMINE DAMAGE
IF D200 THEN MSG$="GOBBO DEAD!":GOSUB ERASEENEMY:GOSUB ALERT
RETURN

INT(RND(1)*20)+1 is the d20. RND(1) gives a fraction from 0 up to (but never quite reaching) 1, times 20 gives 0 to 19.999, INT chops the fraction off leaving a whole number 0 to 19, so the +1 shifts it to 1 to 20. That pattern, INT(RND(1)*sides)+1, is every dice roll you will ever need.

INT((STATS(0)-10)/2) is the D&D ability modifier formula, straight out of the rulebook. STATS(0) is strength, 10 is average, and every two points above average gets you +1 to hit. Strength 16 gives you +3, strength 10 gives you +0, strength 7 gives you… something interesting, because INT on the C64 rounds towards negative infinity rather than towards zero, so INT(-1.5) is -2, not -1. That happens to match the tabletop rules exactly, which is a nice accident. And yes, MOD is a legal variable name here, unlike in a lot of other BASICs, because Commodore BASIC has no MOD operator to clash with.

The 8 in IF D20 is the goblin’s Armour Class, hard-coded. Beat it and your hit connects. Miss and you take 5 damage, which is not how tabletop works at all (a miss should just be a miss), but it keeps the pressure on until proper goblin attack rolls arrive. Change that 8 if you want an easier or a nastier dungeon, that one number is your only difficulty setting right now.

R=RND(-TI) at the top reseeds the generator from the system clock. TI is a jiffy counter that the C64 increments 60 times a second from the moment you switch it on, and passing RND a negative number reseeds it rather than asking for the next number in the sequence. Without a reseed somewhere you’d get the same sequence of rolls every single time you ran the game, which is a great way to test and a terrible way to play.

One small ugliness to warn you about: STR$(DMG) always puts a leading space in front of a positive number, because Commodore BASIC reserves that column for a minus sign. That’s why the message reads “HIT GOBBO WITH 3 DAMAGE!” with a double space. MID$(STR$(DMG),2) would chop it off if it bothers you at this stage.

The bug that ate my character set

Now for the weird bit. Adding those goblin arrays broke the game in a way that looked nothing like an array problem.

The screen started glitching, text came out mangled. My “USE QAOP KEYS” prompt rendered as USE Q O P K Y S and various characters went missing entirely or turned into garbage. Nothing was crashing as such, in that there were no errors reported, the game just gradually stopped being able to spell.

The clue is in what broke. Something was overwriting my character set.

How C64 BASIC uses memory

To understand this you need to know how BASIC lays itself out in RAM, and it’s quite simple once you’ve been over it once or twice. Your program sits at the bottom, starting at address 2049. Immediately above it BASIC puts your scalar variables (integer, float, etc). Above those go your arrays. Things grow upwards as your program gets bigger and you declare more variables or arrays.

Strings are the odd ones out. They start at the top of BASIC memory and grow downwards, towards everything else. The two piles drive towards each other, and BASIC’s job is to notice when they’re about to collide and give you ?OUT OF MEMORY ERROR instead.

The address where that top-of-memory line is drawn lives in two bytes of zero page, 55 and 56, low byte first. BASIC checks against it but, and here’s where I messed up, only BASIC respects it. The VIC-II video chip doesn’t give a hoot and will happily read character data out of memory that BASIC is actively pushing variables into.

Why my character set was parked in the way

The VIC-II can only see 16K of memory at a time, and by default that slot is the bottom 16K, addresses 0 to 16383. Your custom character set has to live inside that area or the video chip cannot see it, no matter how much free RAM you have elsewhere. So all that lovely empty space above 16384 is useless for a charset, and everything has to be squeezed in down at the bottom, where BASIC also lives. That’s the bug right there in one sentence.

A single C64 character set is 2K. 256 characters, 8 bytes each, one byte per pixel row. But the ROM ships two of them, for the uppercase/graphics set and the lowercase set, and they live next to each other as a 4K block. When I originally built my custom charset file contained both, all 4K of it, and I sat it at address 12288, running up to 16383.

Except I never use the lowercase set because this game never switches case. So half of that 4K was wasted, and it was parked directly in the path of my ever-growing arrays. Once the program and variables grew up from 2049 to hit 12288, it started writing goblin coordinates right over the top of the letter A.

The solution is to trim the charset to just the 2K I actually need, shove it to the very top of the VIC’s window, and then tell BASIC to keep away.

AddressPart 7 (4K charset)Part 8 (2K charset)
2049 upBASIC program, variables, arraysBASIC program, variables, arrays
12288character set starts (collision)still free for BASIC
14336character set (lowercase, unused)character set starts, BASIC stops here
16383end of VIC-II’s 16K windowend of VIC-II’s 16K window

Moving the charset from 12288 to 14336 gives 2048 bytes back to BASIC, which is enough to keep growing for a good while. That’s why this version loads CHARS8C.BIN instead of CHARS.BIN. Same character set, but half the file size, and parked safely out of the way.

Pointing the VIC at the new address

The video chip is told where to find its characters by register 53272, and the number in it changed from 12 to 14:

POKE 53272,(PEEK(53272)AND240)+14

That line is doing three separate things. Register 53272 is shared: the top four bits say where screen memory is, the bottom four say where character memory is. We only want to change half of it and leave the rest exactly as we found it.

PEEK(53272) reads what’s there now. AND 240 masks off the bottom four bits and keeps the top four (240 is 11110000 in binary), so we hold on to the screen memory setting we don’t want to touch.

+14 writes our new charset setting into the gap we just cleared. Read, mask, replace. Do this instead of poking a raw number whenever a register can do two jobs at once.

As for why 14 means 14336: three of those bottom four bits are a character memory pointer, and the bottom bit is unused. So the value is effectively doubled before it’s used, giving 14 divided by 2 = 7, times 2048 bytes = 14336. Handily the useful values just go up in twos, and each step is 2K:

ValueCharacter memory atNotes
00unusable, zero page and stack live here
44096
88192
1212288where my 4K charset used to start
1414336where the trimmed 2K charset lives now

Telling BASIC where to stop

Moving the charset is only half the battle. BASIC still thinks it owns the memory all the way up to 40959 and will cheerfully expand into 14336 the moment it needs to. So we have to move its cage down:

REM MEMORY LIMITATION MITIGATION:
IF PEEK(2)73 THEN POKE 55,0:POKE 56,56:POKE 2,73:CLR

One line, four pokes, and every single one of them needs explaining.

POKE 55,0 and POKE 56,56 set that top-of-memory pointer. The 6502 stores addresses low byte first, so this is 56 times 256, plus 0, which is 14336. Exactly where the character set now starts. From this point on, BASIC believes the machine ends there and will raise an ?OUT OF MEMORY ERROR rather than silently vandalising my font.

CLR is what makes it work. Changing the pointer on its own achieves nothing, because BASIC thinks it has already worked out where the strings need to live based on the old setting. CLR is needed to set all its internal bookkeeping to match the pointers as they now stand. The problem is CLR does exactly what it says on the tin, it wipes every variable, every array, every open file, and resets the stack. It’s a scorched earth reset, and is why this line has to run before anything else in the program.

POKE 2,73 and the loop that never ends

Which brings us to the strange part: POKE 2,73, and checking PEEK(2)73.

Our program runs from the start more than once. Loading the character set from disk in BASIC restarts the program (that’s what LOAD from within a program does on a C64), which is why part seven had the A=A+1 counter deciding whether this pass should load the charset or set it up:

IF PEEK(2)73 THEN POKE 55,0:POKE 56,56:POKE 2,73:CLR
IF A=0 THEN PRINT CHR$(147)"LOADING, PLEASE WAIT "
A=A+1
IF A = 1 THEN GOTO CHARS
IF A = 2 THEN GOTO SETCHARS
CHARS:
LOAD "CHARS8C.BIN",8,1
SETCHARS:
POKE 53272,(PEEK(53272)AND240)+14

That counter works because LOAD restarts the program but leaves variables intact. Which is what CLR destroys. So I can’t use a normal variable as my “have I already done the memory fix” flag, because the thing the flag guards is the thing that erases it. Run it once, CLR wipes A back to zero, program restarts, sees the pointer needs fixing again, CLR, wipe, restart, forever.

So the flag has to live somewhere CLR doesn’t reach and that’s what memory location 2 is. It’s a byte in zero page, the fastest and most fought-over 256 bytes on a 6502 machine. 2 is one of the tiny handful that neither BASIC nor the KERNAL uses for anything. It’s spare, and it survives CLR. The only thing that clears it is powering off.

Using 73 as the value is arbitrary. Any value would do, 73 is just the best number (after 42). It just needs to be something unlikely to be sitting there by chance as a trustworthy answer. First run, location 2 holds whatever junk was there, so the test passes, we fix the pointers, drop our marker, and CLR. Restart, and now PEEK(2) is 73, so we skip straight past. No loop.

It’s not bulletproof, there is a one in 256 chance address 2 happens to contain 73 on a cold boot, in which case the fix never runs and you’re back to eating away at the character set. I’ll take those odds.

This whole episode is a lesson that clearly demonstrates that on an 8-bit machine, memory is a shared resource that YOU are responsible. BASIC, the VIC-II, the KERNAL and your code all work on the same 64K, and nobody stops you from shooting your own foot off. It will just cheerfully start behaving strangely and let you figure out why.

What’s next

There’s a FIRE: subroutine in there that does nothing at all right now, which tells you where my head is, but here is a list for future parts:

  • Goblins that hurt – they need to actually hit back instead of only inflict -5 damage on a miss.
  • Fair enemy generation – At the moment a random map can roll zero goblins, or too many too soon.
  • Win and lose conditions – right now your health can currently go negative and nothing happens.
  • More than one type of enemy – Means I can show movement patterns other than “walk toward the player”.
  • Fog of war – Make it so you don’t get the whole dungeon map displayed on turn one.
  • Stairs and levels – Progress, so there’s somewhere to go.

The whole program is up in the IDE if you want to peek and poke at it: part8d.bas. Perhaps, change the goblin’s AC from 8 to something crueller, or give the goblins a rule that makes them smarter. If you do build something out of it, I’d love to see it!

联系我们 contact @ memedata.com