Skip to content

A Two-Word Adventure Parser

Turn the player's words into commands your game can understand. A reusable, vocabulary-driven parser with a complete tiny adventure.

Type GET LIGHT. The game recognises the action, finds the lamp and puts it in your inventory. Type TAKE ROCK and it understands the words just as well—but there is no rock here to pick up. Those are different jobs. Keeping them apart makes both easier to reuse.

This component turns a line into a verb ID and an optional noun-concept ID. You supply the vocabulary; your game supplies the world and decides what the command does. There is no room map, inventory limit or fixed set of English verbs hidden in the parser.

Code status: maintained BASIC component with configuration-specific ROM execution checks. Parser fixtures compare results with independent command rules and reject a deliberately broken extra-word check. A separate check types into the adventure and examines its displayed replies. These are paced 48K emulator checks, not original-hardware certification.

Parser execution record · Keyboard caller checks

Play the small example

The original sample has three rooms, a lamp, a key and a door. Try GET LIGHT, GO NORTH, GO NORTH, TAKE KEY, GO NORTH. The first attempt at the door fails; carrying the key opens it, and it stays open. LOOK, INVENTORY, EXAMINE LAMP and HELP give you other ways to explore. QUIT stops the program.

Build the sample folder with make, using Build198x on your path, then load build/demo.tap on a 48K Spectrum. It runs from line 10. Enter commands without quotes. The reply stays visible until the next submission; the caller clears the display between submissions rather than accumulating a BASIC scroll? prompt.

The three files are demo.bas, parser.bas and vocabulary.bas. The Makefile joins them in line-number order, checks their listed form and builds the tape. The assembly companion implements the same grammar using counted bytes and static tables.

Original three-room adventure running on a Spectrum; the player has reached the courtyard.

Capture from the original Code198x sample in Emu198x on the recorded 48K configuration.

Include, initialise, call

Lift parser.bas and vocabulary.bas into another program, keeping their line numbers clear of your own. This is the smallest calling sequence:

BASIC
  10 GO SUB 6000
  20 IF s<>0 THEN PRINT "Vocabulary error": STOP
  30 INPUT "> "; LINE a$
  40 GO SUB 5000
  50 IF s=0 THEN PRINT "Verb ";v;" noun ";n
  60 GO TO 30

Initialise once with GO SUB 6000 and check its status. Then supply a$ and call GO SUB 5000 once per submitted command. The parser does not change a$ and needs no timer or frame loop.

Value Meaning after parsing
s Status; zero means a recognised command
v Canonical verb ID
n Canonical noun-concept ID, or zero when absent
w$, o$ Normalised first and second words for messages

Both IDs reset to zero at the start of every parse. An unknown command after TAKE LAMP must not accidentally take something using yesterday’s IDs. Copy anything you need before another call. Diagnostic strings can be empty on an early rejection; they are not source-span records.

Initialisation defines the state, sets readiness to zero, and marks it ready only after validation. Parsing after a failed initialisation returns status 9. Calling the routine in a cold BASIC environment without defining its variables is still an interpreter error: call 6000 first.

Make the vocabulary yours

Editable vocabulary DATA

vocabulary.bas
7000 REM Counts: verb aliases, noun aliases
7010 DATA 14,9
7020 REM Verb alias, canonical ID, noun policy0/1/2
7030 DATA "LOOK",1,0,"L",1,0,"GO",2,1
7040 DATA "TAKE",3,1,"GET",3,1,"DROP",4,1
7050 DATA "INVENTORY",5,0,"I",5,0,"EXAMINE",6,1,"X",6,1
7060 DATA "QUIT",7,0,"HELP",8,0,"WAIT",9,0,"SEARCH",10,2
7070 REM Noun alias, canonical ID: concepts, not world object indexes
7080 DATA "LAMP",1,"LIGHT",1,"KEY",2,"NORTH",3,"N",3
7090 DATA "SOUTH",4,"S",4,"DOOR",5,"ROCK",6

The first DATA pair counts aliases, not distinct actions. Each verb record supplies a spelling, ID and noun policy: 0 takes no noun, 1 requires a noun, and 2 permits an optional noun. Thus LOOK, TAKE LAMP, SEARCH and SEARCH LAMP can share the component.

GET and TAKE share verb ID 3. LIGHT and LAMP share noun ID 1. Your game handles each meaning once. Verb and noun IDs have separate namespaces.

Add records and update the alias counts to extend the vocabulary. A custom OPEN/BOX dictionary is tested without changing the parser. You still need to connect the new verb ID to an action in your game. Recognising a verb does not implement it.

Aliases are exact: GETTER is not GET, and LAM is not LAMP. Short forms exist only when listed. The sample accepts GO N; bare N is an unknown verb, because the parser does not invent a missing GO. Keep IDs stable if action dispatch, object classes or saved data depend on them.

Grammar and failures

Input is at most 63 characters of ASCII letters, digits and ordinary spaces. Lowercase folds to uppercase. Leading, trailing and repeated spaces are ignored. There are at most two tokens, and dictionary aliases are 1–12 uppercase letters/digits.

Numbers are vocabulary words here, not automatically numeric arguments. Tabs, punctuation, quoted phrases and non-ASCII characters are rejected. This is a bounded starting grammar, not a claim to interpret every Spectrum character.

Checks run in this order: readiness, total length, all character values, word count, verb lookup, verb argument policy, noun lookup. A three-word line returns 4 even if its first word is unknown. LOOK MYSTERY reports that LOOK takes no noun rather than searching the noun table.

s Meaning Caller response
0 Recognised command Dispatch it to the game
1 Empty or spaces only Prompt again
2 Over 63 characters Ask for a shorter command
3 Unsupported character Explain the accepted input
4 More than two words Ask for a shorter construction
5 Unknown verb Use w$ in a helpful message
6 Unknown noun Use o$ in a helpful message
7 Required noun missing Ask “TAKE what?”
8 Unexpected noun Explain that this verb stands alone
9 Not ready after failed initialisation Repair the program’s setup
10 Invalid dictionary at initialisation Repair the author-supplied DATA

Parsing success need not spend a turn. LOOK might be free, WAIT might advance time, and a failed action might have another policy. The caller owns that decision.

The reusable implementation

The scanner validates the whole line, collects two normalised strings, then searches the appropriate tables. Stored alias lengths prevent fixed-width padding or short prefixes from becoming false matches.

Parser, initialisation and validation

parser.bas
5000 REM PARSE a$: s status, v verb, n noun
5010 LET s=0: LET v=0: LET n=0: LET w$="": LET o$=""
5020 IF pr<>1 THEN LET s=9: RETURN
5030 LET ll=LEN a$: IF ll>63 THEN LET s=2: RETURN
5040 REM Check the entire input before looking up words
5050 FOR p=1 TO ll
5060 LET tc=CODE a$(p): IF tc>=97 AND tc<=122 THEN LET tc=tc-32
5070 IF tc<>32 AND (tc<48 OR tc>90 OR (tc>57 AND tc<65)) THEN LET s=3: RETURN
5080 NEXT p
5090 LET wc=0: LET p=1
5100 IF p>ll THEN GO TO 5230
5110 IF a$(p)=" " THEN LET p=p+1: GO TO 5100
5120 LET wc=wc+1: IF wc>2 THEN LET s=4: RETURN
5130 LET t$=""
5140 IF p>ll THEN GO TO 5190
5150 IF a$(p)=" " THEN GO TO 5190
5160 LET tc=CODE a$(p): IF tc>=97 AND tc<=122 THEN LET tc=tc-32
5170 LET t$=t$+CHR$ tc: LET p=p+1
5180 GO TO 5140
5190 IF wc=1 THEN LET w$=t$
5200 IF wc=2 THEN LET o$=t$
5210 GO TO 5100
5230 IF wc=0 THEN LET s=1: RETURN
5240 LET iv=0: LET f=0: IF LEN w$>12 THEN LET s=5: RETURN
5250 FOR i=1 TO nv
5260 IF w$=d$(i,1 TO LEN w$) AND LEN w$=m(i) THEN LET iv=u(i): LET f=r(i)
5270 NEXT i
5280 IF iv=0 THEN LET s=5: RETURN
5290 IF wc=1 AND f=1 THEN LET s=7: RETURN
5300 IF wc=2 AND f=0 THEN LET s=8: RETURN
5310 LET ni=0: IF wc=1 THEN GO TO 5360
5315 IF LEN o$>12 THEN LET s=6: RETURN
5320 FOR i=1 TO nn
5330 IF o$=e$(i,1 TO LEN o$) AND LEN o$=h(i) THEN LET ni=z(i)
5340 NEXT i
5350 IF ni=0 THEN LET s=6: RETURN
5360 LET v=iv: LET n=ni: RETURN
6000 REM Initialise dictionary from DATA7000
6010 LET pr=0: LET s=10: RESTORE 7000: READ nv,nn
6020 IF nv<1 OR nv>32 OR nv<>INT nv OR nn<0 OR nn>32 OR nn<>INT nn THEN RETURN
6030 DIM d$(nv,12): DIM u(nv): DIM r(nv): DIM m(nv)
6040 DIM e$(nn+1,12): DIM z(nn+1): DIM h(nn+1)
6050 FOR i=1 TO nv
6060 READ t$,kk,jj: GO SUB 6500: IF ok=0 THEN RETURN
6070 IF kk<1 OR kk>255 OR kk<>INT kk OR jj<0 OR jj>2 OR jj<>INT jj THEN RETURN
6080 FOR b=1 TO i-1
6090 IF t$=d$(b,1 TO LEN t$) AND LEN t$=m(b) THEN RETURN
6100 IF kk=u(b) AND jj<>r(b) THEN RETURN
6110 NEXT b
6120 LET d$(i)=t$: LET u(i)=kk: LET r(i)=jj: LET m(i)=LEN t$
6130 NEXT i
6140 FOR i=1 TO nn
6150 READ t$,kk: GO SUB 6500: IF ok=0 THEN RETURN
6160 IF kk<1 OR kk>255 OR kk<>INT kk THEN RETURN
6170 FOR b=1 TO i-1
6180 IF t$=e$(b,1 TO LEN t$) AND LEN t$=h(b) THEN RETURN
6190 NEXT b
6200 LET e$(i)=t$: LET z(i)=kk: LET h(i)=LEN t$
6210 NEXT i
6220 LET pr=1: LET s=0: RETURN
6500 REM A dictionary word is1..12 uppercase letters/digits
6510 LET ok=0: IF LEN t$<1 OR LEN t$>12 THEN RETURN
6520 FOR q=1 TO LEN t$
6530 LET tc=CODE t$(q)
6540 IF tc<48 OR tc>90 OR (tc>57 AND tc<65) THEN RETURN
6550 NEXT q
6560 LET ok=1: RETURN

This bounded version uses a 32-alias budget per table. That is a configurable implementation choice, not the size of every adventure vocabulary or a limit on the Pattern Library. Raising it needs a new memory/startup-cost check as well as changes to the validation bound.

Initialisation accepts 1–32 verb aliases and 0–32 noun aliases, with integer IDs from 1 to 255. It rejects duplicate spellings within a table, invalid lengths/characters, and aliases of one verb ID with conflicting argument policies. A spare noun row permits a zero-noun dictionary.

It does not trap BASIC’s own READ errors. Missing DATA, wrong types or too few records can stop the interpreter. Those are authoring errors; run the build and execution checks after vocabulary changes.

State and line numbers

Sinclair BASIC has no local-variable namespace. Reserve numeric scalars pr, s, v, n, ll, tc, wc, iv, ni, f, kk, jj, ok, nv, nn, i, p, b, q; strings w$, o$, t$; string arrays d$, e$; and numeric arrays u, r, m, z, h. The caller supplies a$.

Do not reuse this state during a call. The demo uses j for its object loops. Rename consistently if your program already owns these names; Sinclair FOR control variables must stay single letters.

The supplied routine ranges are 5000–5360, 6000–6220 and 6500–6560, with vocabulary DATA from 7000. Renumber GO TO, GO SUB and RESTORE references together. Initialisation changes the DATA pointer, so restore your own DATA before your next READ. The component is not reentrant.

What it costs

Each fixed dictionary row reserves 12 characters. Separate numeric arrays hold lengths, IDs and policies. Scanning also creates temporary strings. Initialisation compares earlier aliases to detect duplicates, so its work grows faster than one lookup; initialise at startup or after changing the vocabulary.

This is readable BASIC for submitted commands. Variable lookup, substring operations and allocation cost work. There is no constant-time, per-frame or universal response-time claim. Build198x reports the full demonstration’s stored size, which is not the parser’s working-memory bill. The assembly companion makes the storage trade-off explicit.

Recognition, resolution, rules

The parser returns a noun concept. A particular brass key in the north room is a world object. A second brass key could share the same concept without being the same physical thing.

The caller keeps an object’s concept in c(j) and its room in l(j); zero means carried. Its resolver checks both concept and location. No match produces “not here”; two matches produce an ambiguity response rather than choosing whichever object is stored first. A separate two-matching-objects variation is tested. The current demo cannot answer that ambiguity with an adjective yet.

Follow GET LIGHT: the parser returns verb 3 and noun 1. The TAKE handler asks the resolver for an accessible object of that concept. It refuses an already-carried object or changes its location to zero. DROP performs the inverse rule. The door checks for a carried key and remembers its unlocked state. None of those rules belongs in the word lookup.

Complete three-room adventure caller

demo.bas
  10 BORDER 0: PAPER 0: INK 7: CLS
  20 LET pr=0: GO SUB 6000
  30 IF s<>0 THEN PRINT "Vocabulary error ";s: STOP
  40 LET rm=1: LET un=0: DIM l(2): DIM c(2)
  50 LET l(1)=1: LET c(1)=1: LET l(2)=2: LET c(2)=2
  60 PRINT "THE LANTERN ROOM"'"A tiny parser demonstration."'"HELP lists the commands."
  70 GO SUB 1000
 100 INPUT "> "; LINE a$
 105 CLS : PRINT "THE LANTERN ROOM"
 110 GO SUB 5000
 120 IF s=1 THEN GO TO 100
 130 IF s<>0 THEN GO SUB 2000: GO TO 100
 135 IF v>10 THEN PRINT "No action wired for ID ";v: GO TO 100
 140 IF v=1 THEN GO SUB 1000
 150 IF v=2 THEN GO SUB 1200
 160 IF v=3 THEN GO SUB 1400
 170 IF v=4 THEN GO SUB 1500
 180 IF v=5 THEN GO SUB 1600
 190 IF v=6 THEN GO SUB 1700
 200 IF v=7 THEN PRINT "Goodbye.": STOP
 210 IF v=8 THEN PRINT "LOOK, GO NORTH/SOUTH, TAKE name,"'"DROP name, INVENTORY, EXAMINE name"'"SEARCH [name], WAIT, QUIT."
 220 IF v=9 THEN PRINT "You wait. Nothing changes."
 230 IF v=10 THEN GO SUB 1800
 240 GO TO 100
1000 IF rm=1 THEN PRINT '"You are in a small store room."'"A passage leads north."
1010 IF rm=2 THEN PRINT '"A narrow hall. A door"'"stands north; the store is south."
1015 IF rm=2 AND un=0 THEN PRINT "The door is locked."
1020 IF rm=3 THEN PRINT '"You stand in an open courtyard."'"The hall is south."
1030 FOR j=1 TO 2
1040 IF l(j)=rm THEN PRINT "You can see a ";: GO SUB 1900: PRINT "."
1050 NEXT j
1060 RETURN
1200 IF n<>3 AND n<>4 THEN PRINT "Choose NORTH or SOUTH.": RETURN
1210 IF n=4 AND rm=1 THEN PRINT "There is no way south.": RETURN
1220 IF n=3 AND rm=3 THEN PRINT "There is no way north.": RETURN
1230 IF n<>3 OR rm<>2 OR un=1 THEN GO TO 1260
1232 LET ky=0
1234 FOR j=1 TO 2
1236 IF c(j)=2 AND l(j)=0 THEN LET ky=1
1238 NEXT j
1240 IF ky=0 THEN PRINT "The door is locked. Find a key.": RETURN
1250 LET un=1: PRINT "Your key opens the door."
1260 IF n=3 THEN LET rm=rm+1: GO SUB 1000: RETURN
1270 LET rm=rm-1: GO SUB 1000: RETURN
1400 GO SUB 4000: IF ob=0 THEN RETURN
1410 IF l(ob)=0 THEN PRINT "You are already carrying it.": RETURN
1420 LET l(ob)=0: PRINT "Taken.": RETURN
1500 GO SUB 4000: IF ob=0 THEN RETURN
1510 IF l(ob)<>0 THEN PRINT "You are not carrying it.": RETURN
1520 LET l(ob)=rm: PRINT "Dropped.": RETURN
1600 LET ct=0: PRINT "You are carrying:"
1610 FOR j=1 TO 2
1620 IF l(j)=0 THEN GO SUB 1900: PRINT : LET ct=ct+1
1630 NEXT j
1640 IF ct=0 THEN PRINT "Nothing."
1650 RETURN
1700 IF n=5 AND rm=2 THEN PRINT "A key should fit this lock.": RETURN
1710 GO SUB 4000: IF ob=0 THEN RETURN
1720 IF c(ob)=1 THEN PRINT "A sturdy lamp. It has no switch."
1730 IF c(ob)=2 THEN PRINT "A small brass key."
1740 RETURN
1800 IF n=0 THEN PRINT "Nothing is hidden here.": RETURN
1810 GO SUB 1700: RETURN
1900 IF c(j)=1 THEN PRINT "lamp";: RETURN
1910 IF c(j)=2 THEN PRINT "small key";: RETURN
1920 PRINT "object";: RETURN
2000 IF s=2 THEN PRINT "Please use at most63characters."
2010 IF s=3 THEN PRINT "Use letters, digits and spaces."
2020 IF s=4 THEN PRINT "Try one or two words."
2030 IF s=5 THEN PRINT "I do not know the verb ";w$
2040 IF s=6 THEN PRINT "I do not know the noun ";o$
2050 IF s=7 THEN PRINT w$;" what?"
2060 IF s=8 THEN PRINT w$;" does not take a noun."
2070 IF s=9 THEN PRINT "Parser is not initialised."
2080 RETURN
4000 REM Resolve a noun concept among nearby/carried objects
4010 LET ob=0: LET ct=0
4020 FOR j=1 TO 2
4030 IF c(j)=n AND (l(j)=rm OR l(j)=0) THEN LET ob=j: LET ct=ct+1
4040 NEXT j
4050 IF ct=0 THEN PRINT "You cannot see that here.": RETURN
4060 IF ct>1 THEN LET ob=0: PRINT "Two match; this demo cannot choose."
4070 RETURN

Where a richer parser can go

These are design branches, not implemented features. A carefully chosen two-word vocabulary can be the right finished interface. More complexity should earn its cost through something the player can do.

More words, with the same grammar

Add explicit aliases and keep the IDs. More table data and lookup work need not change the command structure. A large dictionary might justify sorted lookup or a prefix tree; those alternatives spend code and representation complexity to change lookup cost.

Automatic abbreviation is another policy. Several words may share a prefix, so choosing the first silently makes commands depend on table order. Ask for a longer spelling or report ambiguity. Exact aliases avoid that problem at this scale.

Noun phrases and clarification

TAKE BLUE LAMP needs a noun phrase rather than another fixed word slot. A resolver can intersect candidates: blue things, lamps, and things currently in scope. Zero, one and several candidates need different replies.

“The blue lamp or the brass lamp?” introduces conversation state. The next input may answer a question rather than begin another command. Provide a way to cancel or replace that pending request. A bare noun after this parser’s “TAKE what?” does not automatically do that.

Prepositions and two objects

UNLOCK DOOR WITH KEY needs an action, target and instrument. PUT KEY IN BOX describes another relationship. Grammar assigns those roles; it does more than count extra words. Return a structured command instead of making every action recover roles from raw strings.

Whether the key fits or the box is open still belongs to the world and rules. Grammar should not become a second, contradictory version of those rules.

Pronouns, lists and quoted text

TAKE IT needs a remembered referent and a policy when it leaves scope. TAKE KEY AND LAMP needs an object list; TAKE KEY AND GO NORTH could contain two commands. Splitting every AND before understanding the construction confuses those cases.

SAY "OPEN SESAME" adds quoted text whose internal spaces matter. That changes tokenisation, representation and buffer ownership. Preserve useful error positions and explicit limits; never quietly discard an overlong command’s tail.

Each extension should bring a working caller, representative failures and storage/work costs on its target system. A richer parser can be useful on one machine without needing thirty ports.

Connections and verification

The Adventure study explores how described places and interpreted commands create a world larger than its display. The text-adventure genre and Zork provide further context. This component does not claim to reproduce their parsers.

The sample README gives the build and verification commands. Tests cover aliases, exact matches, whitespace, stale results, invalid/custom/boundary dictionaries and extra words. A mutation that accepts a third word must fail the independent oracle. Actual keyboard interaction and the complete caller are checked separately from the parser fixtures.