NCL 411: Optimizing the Game
Our Noughts and Crosses program has been through two major refactoring passes.
In NCL 409, repeated operations became reusable interfaces.
In NCL 410, repeated relationships became data.
The resulting program has a structure we're happy with. The board remains in registers. Winning lines and move priorities live in indexed data. Subroutines communicate through explicit arguments and results. The computer player operates on those representations instead of spelling out every individual case.
We're going to keep all of that.
This time, we'll change the physical layout of the source.
The Cost of a Source Line
In NCL 407, we learned that fetching a new physical source line is relatively expensive.
Once a line has been fetched, the processor can perform several operations from that line without fetching another one.
That means these two pieces of source don't have the same execution cost:
MOVE r0 0
INC r0
MUL r0 r0 2
MOVE r0 0; INC r0; MUL r0 r0 2
They perform the same operations.
The second form requires only one source-line fetch.
We deliberately didn't optimize the game this way while restructuring it. Separate physical lines made the changing program easier to read and reason about.
Now its structure is settled.
Optimization is not compression.
Our goal isn't to fit as many instructions as possible onto every line.
Our goal is to place source-line boundaries where execution actually needs them.
Finding the Boundaries
For each section of code, we can ask three questions.
- Does control flow need to enter here?
A branch, jump, call, or return destination must begin a physical source line.
- Does control flow leave here?
An unconditional JUMP, CALL, or RET ends useful execution on its physical line.
- Does the destination have meaning beyond a tiny local construction?
Meaningful destinations deserve labels. Small structural relationships can often use relative destinations.
The principle from NCL 407 still applies:
Start a new executable source line only when control flow needs a new entry point.
Let's apply that to the finished game.
A Single-Line Loop
Our board-clearing loop currently looks like this:
MOVE r0 #board_base
$clear_board
MOVE rr0 0
INC r0
BLT $clear_board r0 #board_limit
The body has only one entry point: its beginning.
Nothing inside the loop needs to be entered independently.
We can therefore put the whole iteration on one physical line:
MOVE r0 #board_base
$clear_board; MOVE rr0 0; INC r0; BLT @0 r0 #board_limit
@0 means the current physical source line.
As long as the loop continues, execution returns to the line the processor already has.
The label remains useful because it names the loop for a human reader. Its internal backward branch only needs to express:
Run this line again.
@0 says exactly that.
This is an especially useful pattern for small CPU-local loops:
$loop; ...; ...; BNEQ @0 ...
The processor can execute the same fetched line repeatedly before its normal execution yield requires another fetch.
Grouping Without a Line Break
Coalescing can make a long physical source line difficult to scan.
Fortunately, a physical line doesn't have to become one uninterrupted wall of instructions.
An empty source element is harmless:
; ;
We'll use the extra spacing to make the separation obvious.
For example:
MUL r0 r1 3; ADD r0 r0 #lines_base; ; MOVE r31 sp; MOVE sp r0; MOVE r1 sv; MOVE sp r31
The empty element creates a visual break between two groups of work while keeping them on the same physical source line.
The same technique works well with declarations:
#check_filter r0; #check_line r1; #check_pointer r2; ; #check_first r3; #check_sum r3; #check_second r4; #check_third r5; ; #check_highest r6; #check_lowest r7
There are three logical groups there:
- loop and address state;
- the values read from a line;
- accumulated results.
They don't need three physical source lines.
We'll use ; ; sparingly when a long line benefits from an internal visual boundary.
Compact Declarations
Many declarations in our program are naturally related.
The board constants currently occupy several lines:
#board_base 20
#board_limit 29
#cell_count 9
They can share one:
#board_base 20; #board_limit 29; #cell_count 9
The same is true of the board aliases:
#cell0 r20; #cell1 r21; #cell2 r22; #cell3 r23; #cell4 r24; #cell5 r25; #cell6 r26; #cell7 r27; #cell8 r28
And the indexed-data constants:
#data_start 64
#lines_base 65; #line_count 8; #line_width 3
#priority_base 89; #priority_count 9
The source still presents the same information.
We're simply no longer giving every closely related declaration its own physical line.
Scratch aliases benefit from the same treatment:
#draw_index r0; #draw_selected r1; #draw_register r2; #draw_value r3; ; #draw_column r4; #draw_row r5; #draw_x r6; #draw_y r7
The routine still gets meaningful scratch names.
They now read as one register map.
Data Has Its Own Shape
The winning-line table from NCL 410 was deliberately written one value per line:
PUSH 0
PUSH 1
PUSH 2
PUSH 3
PUSH 4
PUSH 5
PUSH 6
PUSH 7
PUSH 8
That was useful while learning how the table was constructed.
Now we can group records without losing their shape:
PUSH 0; PUSH 1; PUSH 2; ; PUSH 3; PUSH 4; PUSH 5; ; PUSH 6; PUSH 7; PUSH 8
Better still, the winning lines themselves have useful larger groups:
PUSH 0; PUSH 1; PUSH 2; ; PUSH 3; PUSH 4; PUSH 5; ; PUSH 6; PUSH 7; PUSH 8
PUSH 0; PUSH 3; PUSH 6; ; PUSH 1; PUSH 4; PUSH 7; ; PUSH 2; PUSH 5; PUSH 8
PUSH 0; PUSH 4; PUSH 8; ; PUSH 2; PUSH 4; PUSH 6
Those three physical lines represent:
- rows;
- columns;
- diagonals.
The move-priority sequence fits particularly well on one line:
PUSH 4; ; PUSH 0; PUSH 2; PUSH 6; PUSH 8; ; PUSH 1; PUSH 3; PUSH 5; PUSH 7
The gaps preserve its logical groups:
- center;
- corners;
- edges.
We could put every initialization PUSH in the program onto one enormous line.
That would save another couple of startup fetches.
It wouldn't improve the program much.
This code runs once, and the existing grouping tells us something useful about the data.
Coalesce by unit of thought, not maximum density.
Peripheral Work Still Costs Peripheral Work
The static board drawing also contains many consecutive instructions:
D.CUR 11 2
D.TXT #empty
D.TXT "\uE502"
D.TXT #empty
D.TXT "\uE502"
D.TXT #empty
We can reduce the CPU's source fetching:
D.CUR 11 2; D.TXT #empty; D.TXT "\uE502"; D.TXT #empty; D.TXT "\uE502"; D.TXT #empty
We'll use one physical source line for each visible row or message:
D.CUR 11 2; D.TXT #empty; D.TXT "\uE502"; D.TXT #empty; D.TXT "\uE502"; D.TXT #empty
D.CUR 11 3; D.TXT "\uE500\uE53C\uE500\uE53C\uE500"
This removes source-fetch overhead.
It does not make the Display operations themselves free.
Peripheral verbs still communicate with a peripheral and can pause the CPU while that work occurs.
The same applies to:
SYS.AKEY #key
The shell may spend a long time waiting for the player to press a key. Saving one source fetch around that wait isn't an important performance victory.
We should still lay the source out sensibly, but optimization effort is more valuable in CPU-local work that executes repeatedly.
The Input Loop
Our input dispatch has one natural entry point:
$input
It waits for a key, checks the possible actions, and repeats if no recognized key was received.
That can become one physical source line:
$input; SYS.AKEY #key; ; BSEQ $move_left #key "LEFT"; BSEQ $move_right #key "RIGHT"; BSEQ $move_up #key "UP"; BSEQ $move_down #key "DOWN"; BSEQ $place #key "ENTER"; BSEQ $exit #key "ESC"; JUMP @0
The blank element visually separates input from dispatch.
The final JUMP @0 expresses the local loop directly.
The movement destinations retain labels because they have semantic meaning:
$move_left
$move_right
$move_up
$move_down
Those are useful names.
A CALL Ends the Line
Consider the left-movement path:
$move_left
MOD r0 #selection 3
BEQ $input r0 0
PUSH -1
CALL $move_selection
JUMP $input
Most of that can be coalesced:
$move_left; MOD r0 #selection 3; BEQ $input r0 0; PUSH -1; CALL $move_selection
JUMP $input
The final line break is required.
CALL remembers the next physical source line as its return destination.
If we wrote:
PUSH -1; CALL $move_selection; JUMP $input
the JUMP would never execute after the return.
The call returns to the next physical source line, not to the instruction following CALL within the same line.
The same rule applies to unconditional jumps and returns:
JUMP ...
CALL ...
RET
Anything after one of those on the same physical line is unreachable.
Optimization still has to respect control flow.
Local Structure With Relative Destinations
Labels are valuable when a destination names a meaningful part of the program.
Some destinations exist only to arrange a tiny local decision.
Relative destinations are often clearer there.
Suppose we have this ordinary if/else:
BEQ $selected #draw_selected 1
D.COL #D.COL.WHITE #D.TXT.NORMAL
JUMP $paint
$selected
D.COL #D.COL.WHITE #D.TXT.INVERT
$paint
D.CUR #draw_x #draw_y
D.CHR #glyph
Its labels aren't describing major program concepts.
They're describing physical relationships between a few nearby lines.
We can express those relationships directly:
BEQ @2 #draw_selected 1
D.COL #D.COL.WHITE #D.TXT.NORMAL; JUMP @2
D.COL #D.COL.WHITE #D.TXT.INVERT
D.CUR #draw_x #draw_y; D.CHR #glyph
If the condition is true, @2 jumps two physical lines forward to the inverted-color case.
The normal case then uses its own @2 to jump across that line to the shared drawing code.
This gives us a useful convention:
Use labels for semantic destinations. Use relative destinations for local source structure.
$computer_turn deserves a name.
“The line two physical lines from here” often doesn't.
The Computer Scan
The board scan is one of the game's busiest CPU-local paths.
The readable version from NCL 410 is:
$scan_cell_loop
ADD #scan_register #scan_cell #board_base
BNEQ $next_cell rr1 0
PUSH #scan_cell
PUSH #scan_cell
CALL $check_lines
POP #scan_highest
POP #scan_lowest
POP #scan_cell
BNEQ $check_danger #scan_lowest -2
BNEQ $check_danger #opportunity -1
MOVE #opportunity #scan_cell
$check_danger
BNEQ $next_cell #scan_highest 2
BNEQ $next_cell #danger -1
MOVE #danger #scan_cell
$next_cell
INC #scan_cell
BLT $scan_cell_loop #scan_cell #cell_count
There are several genuine entry points here.
The start of a cell must be reachable from the bottom of the loop.
The instruction after $check_lines must begin a new physical line because the call returns there.
The next-cell path must be reachable when the current board cell is occupied.
Those requirements give us the optimized shape:
$scan_cell_loop; ADD #scan_register #scan_cell #board_base; BNEQ $next_cell rr1 0; PUSH #scan_cell; PUSH #scan_cell; CALL $check_lines
POP #scan_highest; POP #scan_lowest; POP #scan_cell; ; BNEQ @1 #scan_lowest -2; BNEQ @1 #opportunity -1; MOVE #opportunity #scan_cell
BNEQ $next_cell #scan_highest 2; BNEQ $next_cell #danger -1; MOVE #danger #scan_cell
$next_cell; INC #scan_cell; BLT $scan_cell_loop #scan_cell #cell_count
Each physical line has a reason to exist.
The first ends with CALL.
The second is the return destination and handles the winning opportunity.
The third handles danger.
The fourth is the next-cell entry point and loop branch.
The old $check_danger label has disappeared. Its only purpose was to name the immediately following local step, so @1 expresses that structure directly.
The meaningful $next_cell and $scan_cell_loop labels remain.
This is the kind of optimization we're looking for.
Optimizing $check_lines
$check_lines performs even more repeated CPU-local work.
Its readable structure contains several stages:
- calculate the address of a winning-line record;
- read its three cell numbers;
- decide whether the line should be included;
- calculate its sum;
- update the highest and lowest values;
- advance to the next line.
We don't need one physical line for every instruction within those stages.
The routine entry can initialize all of its state at once:
$check_lines; POP #check_filter; MOVE #check_highest -3; MOVE #check_lowest 3; MOVE #check_line 0
The next-line entry can calculate and read one record:
$next_line; MUL #check_pointer #check_line #line_width; ADD #check_pointer #check_pointer #lines_base; ; MOVE r31 sp; MOVE sp #check_pointer; MOVE #check_first sv; INC sp; MOVE #check_second sv; INC sp; MOVE #check_third sv; MOVE sp r31
The gap separates address calculation from the temporary use of sp.
Now consider the filter:
BLT $include_line #check_filter 0
BEQ $include_line #check_filter #check_first
BEQ $include_line #check_filter #check_second
BEQ $include_line #check_filter #check_third
JUMP $skip_line
$include_line and $skip_line are local structural destinations.
We can arrange the next physical lines so their relative positions express the same decision:
BLT @1 #check_filter 0; BEQ @1 #check_filter #check_first; BEQ @1 #check_filter #check_second; BEQ @1 #check_filter #check_third; JUMP @2
The immediately following line is the included-line work:
ADD #check_pointer #check_first #board_base; MOVE #check_sum rr2; ADD #check_pointer #check_second #board_base; ADD #check_sum #check_sum rr2; ADD #check_pointer #check_third #board_base; ADD #check_sum #check_sum rr2; ; MAX #check_highest #check_highest #check_sum; MIN #check_lowest #check_lowest #check_sum
The line after that advances the loop:
INC #check_line; BLT $next_line #check_line #line_count
If a line doesn't match the filter, JUMP @2 skips the calculation and lands directly on that loop-advance line.
Finally:
PUSH #check_lowest; PUSH #check_highest; RET
The routine now has a physical layout that closely follows its control-flow graph.
We didn't change its algorithm.
We didn't change its data.
We changed where the processor has to fetch another line.
A Single-Line Search
The fallback move search is another good candidate.
Its job is simple:
- read one cell number from the priority table;
- see whether that board cell is empty;
- repeat if necessary.
That can fit naturally on one physical loop line:
$fallback_loop; MOVE r31 sp; ADD sp #priority_base #fallback_index; MOVE #fallback_cell sv; MOVE sp r31; ; ADD #fallback_register #fallback_cell #board_base; BEQ @1 rr2 0; INC #fallback_index; BLT @0 #fallback_index #priority_count
MOVE #selection #fallback_cell; JUMP $place
@0 repeats the search line.
@1 moves to the immediately following line when an empty cell is found.
We no longer need a $fallback_found label because “the next physical line” completely describes that destination.
This is a compact loop without becoming mysterious.
Optimize Where the Work Is
Not every part of the game deserves the same attention.
The computer's line analysis runs repeatedly and performs substantial CPU-local work. Reducing source fetches there matters.
The board scan also repeats during computer turns.
Board initialization happens once.
The static display is drawn once.
The keyboard path spends most of its life waiting for a person.
The win messages execute once at the end of a game.
That gives us another useful optimization principle:
Optimize where the program spends its work.
A source file with every possible instruction packed onto giant lines isn't automatically a well-optimized program.
A useful optimization removes cost from important paths while preserving enough structure for the program to remain understandable.
The Optimized Program
Here is the same game with its physical source layout optimized.
Its algorithm and representations are unchanged from NCL 410.
-- Noughts and Crosses
-- Cross is controlled by the keyboard.
-- Nought is controlled by the computer.
-- Persistent state and shared layout.
#selection r8; #player r9; #moves r10; #opportunity r11; #danger r12
#key s0; #glyph s1
#board_base 20; #board_limit 29; #cell_count 9
#cell0 r20; #cell1 r21; #cell2 r22; #cell3 r23; #cell4 r24; #cell5 r25; #cell6 r26; #cell7 r27; #cell8 r28
#data_start 64
#lines_base 65; #line_count 8; #line_width 3
#priority_base 89; #priority_count 9
#empty "\u3000"; #cross "\uE573"; #nought "\uE5CB"
-- Starting state.
MOVE #selection 0; MOVE #player 1; MOVE #moves 0
MOVE r0 #board_base
$clear_board; MOVE rr0 0; INC r0; BLT @0 r0 #board_limit
-- Build indexed game data.
MOVE r31 sp; MOVE sp #data_start
-- Rows.
PUSH 0; PUSH 1; PUSH 2; ; PUSH 3; PUSH 4; PUSH 5; ; PUSH 6; PUSH 7; PUSH 8
-- Columns.
PUSH 0; PUSH 3; PUSH 6; ; PUSH 1; PUSH 4; PUSH 7; ; PUSH 2; PUSH 5; PUSH 8
-- Diagonals.
PUSH 0; PUSH 4; PUSH 8; ; PUSH 2; PUSH 4; PUSH 6
-- Preferred moves: center, corners, edges.
PUSH 4; ; PUSH 0; PUSH 2; PUSH 6; PUSH 8; ; PUSH 1; PUSH 3; PUSH 5; PUSH 7
MOVE sp r31
-- Draw the static board.
D.PALRST; D.COL #D.COL.WHITE #D.TXT.NORMAL; D.FIL " "
D.CUR 11 2; D.TXT #empty; D.TXT "\uE502"; D.TXT #empty; D.TXT "\uE502"; D.TXT #empty
D.CUR 11 3; D.TXT "\uE500\uE53C\uE500\uE53C\uE500"
D.CUR 11 4; D.TXT #empty; D.TXT "\uE502"; D.TXT #empty; D.TXT "\uE502"; D.TXT #empty
D.CUR 11 5; D.TXT "\uE500\uE53C\uE500\uE53C\uE500"
D.CUR 11 6; D.TXT #empty; D.TXT "\uE502"; D.TXT #empty; D.TXT "\uE502"; D.TXT #empty
D.CUR 9 8; D.TXT "ARROWS MOVE"
D.CUR 9 9; D.TXT "ENTER PLACE"
D.CUR 9 10; D.TXT "ESC TO EXIT"
D.BLT
PUSH #selection; PUSH 1; CALL $draw_cell
-- Player input.
$input; SYS.AKEY #key; ; BSEQ $move_left #key "LEFT"; BSEQ $move_right #key "RIGHT"; BSEQ $move_up #key "UP"; BSEQ $move_down #key "DOWN"; BSEQ $place #key "ENTER"; BSEQ $exit #key "ESC"; JUMP @0
$move_left; MOD r0 #selection 3; BEQ $input r0 0; PUSH -1; CALL $move_selection
JUMP $input
$move_right; MOD r0 #selection 3; BEQ $input r0 2; PUSH 1; CALL $move_selection
JUMP $input
$move_up; BLT $input #selection 3; PUSH -3; CALL $move_selection
JUMP $input
$move_down; BGE $input #selection 6; PUSH 3; CALL $move_selection
JUMP $input
-- Place the current player's piece.
$place; PUSH #selection; PUSH #player; CALL $try_place
POP r0; BEQ $input r0 0; JUMP $placed
$placed; INC #moves; PUSH #selection; PUSH 1; CALL $draw_cell
PUSH -1; CALL $check_lines
POP r0; POP r1; ; ABS r2 r1; MAX r2 r0 r2; ; BNEQ $no_winner r2 3; BEQ $cross_wins r0 3; JUMP $nought_wins
$no_winner; BEQ $draw #moves 9; NEG #player #player; BEQ $input #player 1; JUMP $computer_turn
-- Nought's turn.
#scan_cell r0; #scan_register r1; #scan_highest r1; #scan_lowest r2
#fallback_index r0; #fallback_cell r1; #fallback_register r2
$computer_turn; PUSH #selection; PUSH 0; CALL $draw_cell
MOVE #opportunity -1; MOVE #danger -1; MOVE #scan_cell 0
$scan_cell_loop; ADD #scan_register #scan_cell #board_base; BNEQ $next_cell rr1 0; PUSH #scan_cell; PUSH #scan_cell; CALL $check_lines
POP #scan_highest; POP #scan_lowest; POP #scan_cell; ; BNEQ @1 #scan_lowest -2; BNEQ @1 #opportunity -1; MOVE #opportunity #scan_cell
BNEQ $next_cell #scan_highest 2; BNEQ $next_cell #danger -1; MOVE #danger #scan_cell
$next_cell; INC #scan_cell; BLT $scan_cell_loop #scan_cell #cell_count
BEQ @1 #opportunity -1; MOVE #selection #opportunity; JUMP $place
BEQ @1 #danger -1; MOVE #selection #danger; JUMP $place
MOVE #fallback_index 0
$fallback_loop; MOVE r31 sp; ADD sp #priority_base #fallback_index; MOVE #fallback_cell sv; MOVE sp r31; ; ADD #fallback_register #fallback_cell #board_base; BEQ @1 rr2 0; INC #fallback_index; BLT @0 #fallback_index #priority_count
MOVE #selection #fallback_cell; JUMP $place
-- Move the current selection.
$move_selection; #move_amount r0; ; POP #move_amount; PUSH #move_amount; PUSH #selection; PUSH 0; CALL $draw_cell
POP #move_amount; ADD #selection #selection #move_amount; PUSH #selection; PUSH 1; CALL $draw_cell
RET
-- Try to place a value in a board cell.
$try_place; #place_index r0; #place_value r1; ; POP #place_value; POP #place_index; ADD #place_index #place_index #board_base; BNEQ @1 rr0 0; MOVE rr0 #place_value; PUSH 1; RET
PUSH 0; RET
-- Draw one board cell.
$draw_cell; #draw_index r0; #draw_selected r1; #draw_register r2; #draw_value r3; ; #draw_column r4; #draw_row r5; #draw_x r6; #draw_y r7; ; POP #draw_selected; POP #draw_index; ADD #draw_register #draw_index #board_base; MOVE #draw_value rr2; SMOVE #glyph #empty; BEQ @1 #draw_value 1; BEQ @2 #draw_value -1; JUMP @3
SMOVE #glyph #cross; JUMP @2
SMOVE #glyph #nought
MOD #draw_column #draw_index 3; DIV #draw_row #draw_index 3; ; MUL #draw_x #draw_column 4; ADD #draw_x #draw_x 11; MUL #draw_y #draw_row 2; ADD #draw_y #draw_y 2
BEQ @2 #draw_selected 1
D.COL #D.COL.WHITE #D.TXT.NORMAL; JUMP @2
D.COL #D.COL.WHITE #D.TXT.INVERT
D.CUR #draw_x #draw_y; D.CHR #glyph; D.COL #D.COL.WHITE #D.TXT.NORMAL; RET
-- Examine winning lines.
-- Consumes: cell index, or -1 for every line.
-- Produces: highest line sum, lowest line sum.
$check_lines; #check_filter r0; #check_line r1; #check_pointer r2; ; #check_first r3; #check_sum r3; #check_second r4; #check_third r5; ; #check_highest r6; #check_lowest r7; ; POP #check_filter; MOVE #check_highest -3; MOVE #check_lowest 3; MOVE #check_line 0
$next_line; MUL #check_pointer #check_line #line_width; ADD #check_pointer #check_pointer #lines_base; ; MOVE r31 sp; MOVE sp #check_pointer; MOVE #check_first sv; INC sp; MOVE #check_second sv; INC sp; MOVE #check_third sv; MOVE sp r31
BLT @1 #check_filter 0; BEQ @1 #check_filter #check_first; BEQ @1 #check_filter #check_second; BEQ @1 #check_filter #check_third; JUMP @2
ADD #check_pointer #check_first #board_base; MOVE #check_sum rr2; ADD #check_pointer #check_second #board_base; ADD #check_sum #check_sum rr2; ADD #check_pointer #check_third #board_base; ADD #check_sum #check_sum rr2; ; MAX #check_highest #check_highest #check_sum; MIN #check_lowest #check_lowest #check_sum
INC #check_line; BLT $next_line #check_line #line_count
PUSH #check_lowest; PUSH #check_highest; RET
-- Game endings.
$cross_wins; D.CUR 9 9; D.TXT "CROSS WINS! "; D.BLT; JUMP 0
$nought_wins; D.CUR 9 9; D.TXT "NOUGHT WINS! "; D.BLT; JUMP 0
$draw; D.CUR 9 9; D.TXT "DRAW! "; D.BLT; JUMP 0
$exit; JUMP 0
The optimized source is visibly denser than the version from NCL 410.
It hasn't become a compressed puzzle.
The constants still form groups. The data still has visible structure. Scratch registers still have descriptive aliases. Important destinations still have names. Empty elements create visual breathing room inside long physical lines.
The physical line breaks now carry more meaning.
Most of them exist because control flow needs an entry point there.
Try It
Find this loop in $check_lines:
$next_line; MUL #check_pointer #check_line #line_width; ADD #check_pointer #check_pointer #lines_base; ; MOVE r31 sp; MOVE sp #check_pointer; MOVE #check_first sv; INC sp; MOVE #check_second sv; INC sp; MOVE #check_third sv; MOVE sp r31
Split it back into several physical source lines:
$next_line
MUL #check_pointer #check_line #line_width
ADD #check_pointer #check_pointer #lines_base
MOVE r31 sp
MOVE sp #check_pointer
MOVE #check_first sv
INC sp
MOVE #check_second sv
INC sp
MOVE #check_third sv
MOVE sp r31
Run the game and compare the computer's turn.
Then restore the coalesced version.
The operations are identical.
The source-fetch pattern isn't.
We've spent the 400-level learning how larger programs can organize their work. We've used stacks to manage temporary values and subroutine calls, established conventions for sharing registers, moved repeated relationships into data, and arranged our source around the way the processor executes it.
Our Noughts and Crosses program now has well-defined state, data, and subroutines. As applications become more capable, those individual pieces are only part of the structure we need to manage.
In NCL 501: ..., we'll start looking at the application as a whole: what it's doing, how the person using it can ask it to do something else, and what happens when an operation can't be completed.