Build Can't Stop for TI-Nspire CX II CAS
Build a playable Can't Stop board game in Lua for your TI-Nspire calculator.
Introduction
30 Second Summary
Your calculator can do a lot more than solve equations. With a little code, it becomes a portable gaming device you can pull out anytime.
In this project, you will build a complete digital version of the push-your-luck board game Can't Stop, playable against an AI opponent directly on your TI-Nspire CX II CAS calculator.
What You'll Build
A full implementation of Can't Stop written in Lua that renders an 11-column game board, handles dice rolling and pairing selection, and features an AI opponent with probability-based decision-making.
By the end of this project, you'll have:
- A fully playable Can't Stop board game running on your TI-Nspire CX II CAS calculator with colored markers, dice displays, and column tracking.
- A working AI opponent that uses a probability-based heuristic to decide when to push its luck or stop.
- A complete TI-Nspire Lua development workflow you can reuse to build any turn-based calculator game you want next.
- Secret Mission: Add difficulty levels to the AI (Cautious, Normal, Reckless) that the player selects at the start.
What do I need for this project?
You need a TI-Nspire CX II CAS calculator (OS 6.4.0.74), a Chromebook with Chrome browser, and a USB cable to connect them. Everything else is browser-based with no installs required.
Set Up Your Development Workflow
To build a game for your calculator, you need a way to write code, convert it into a format your calculator understands, and transfer it over.
In this step, you will set up a three-part workflow: write Lua code in a text editor, convert it to a .tns file your calculator can run, and transfer it using TI-Nspire CX II Connect.
In this step, get ready to:
- Create your Lua script file and connect your calculator to your Chromebook.
- Write a test script that draws text on screen and transfer it to your calculator.
- Add keyboard input handling and confirm it works on the calculator.
Open your tools and connect your calculator
Your development workflow uses three tools that all run in the browser on your Chromebook. The ChromeOS Text Editor is where you write code. A web-based converter turns your code into a calculator-ready file. TI-Nspire CX II Connect transfers that file to your calculator.
- Press the Launcher key (circle icon) on your Chromebook keyboard to open the app launcher.
- Type Text and open the Text app (the ChromeOS built-in text editor).
- Click File in the top-left, then Save as.
- Name your file cantstop.lua and save it to your Downloads folder.
- Open a new Chrome tab and go to nspireconnect.ti.com.
- If prompted, click Agree and Proceed to accept cookies, then click ACCEPT to agree to the terms and conditions.
- Plug your TI-Nspire CX II CAS into your Chromebook using the USB cable that came with the calculator.
- On the TI-Nspire CX II Connect page, click CONNECT TO CALCULATOR.
- In the popup window, click the name of your calculator and then click Connect.
What is TI-Nspire CX II Connect?
TI-Nspire CX II Connect is TI's official browser-based web app for connecting your calculator to a computer. It uses WebUSB to transfer .tns files between your Chromebook and calculator. No software installation needed.
You should see the TI-Nspire CX II Connect home page with four options: Capture Screen, Transfer File, Update OS, and Exit Press-to-Test.
Calculator not showing up in the popup?
Make sure your calculator is powered on. Try unplugging and re-plugging the USB cable. Chrome needs WebUSB permission to see the device.
help me connect my TI-Nspire CX II CAS to my Chromebook via TI-Nspire CX II Connect
Write a test script and transfer it to your calculator
Your calculator runs Lua scripts embedded inside .tns documents. You cannot directly send a .lua file to the calculator. Instead, you will use a browser-based converter to package your Lua code into a .tns file first.
- Switch to the Text app tab where you have cantstop.lua open.
- Type the following code into your file:
platform.apilevel = '2.2'
local screen = platform.window
local message = "Hello, Can't Stop!"
function on.paint(gc)
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "r", 10)
gc:drawString(message, 10, 10, "top")
end
What does this code do?
- platform.apilevel = '2.2' tells the calculator which version of the scripting API your code uses.
- local screen = platform.window stores a reference to the display window so you can refresh it later.
- on.paint(gc) is the event handler the calculator calls whenever the screen needs to be redrawn. The gc parameter is the graphics context you use to draw shapes and text.
- gc:drawString(message, 10, 10, "top") draws your message at position (10, 10) from the top-left corner.
- Save the file by pressing Ctrl+S.
- Open a new Chrome tab and go to tiplanet.org/scripts/luna-web/.
What is luna-web?
Luna is a tool that converts .lua scripts into .tns documents that TI-Nspire calculators can open. The luna-web page runs this conversion entirely in your browser. No installs required.
- On the luna-web page, upload or drag your cantstop.lua file from your Downloads folder.
- The page will generate a cantstop.tns file and download it to your Chromebook.
- Switch to the TI-Nspire CX II Connect tab.
- Click Transfer File.
- Click TO CALCULATOR.
- Click FROM COMPUTER, then select cantstop.tns from your Downloads folder.
- On your calculator, a prompt will appear. Press Open to run the transferred file.
You should see "Hello, Can't Stop!" displayed in black text near the top-left corner of your calculator screen.
Don't see the text on your calculator?
Make sure you saved cantstop.lua before uploading it to luna-web. The converter uses whatever file you give it.
If the file transferred but nothing appears, navigate to My Documents on your calculator using the home button, find cantstop, and press enter to open it.
help me get my Lua script to display on my TI-Nspire CX II CAS
Add arrow key input and verify it works
Your game will use the calculator's arrow keys for all player interaction. The TI-Nspire Lua API provides an on.arrowKey event handler that fires whenever the player presses an arrow key.
- Switch back to the Text app tab with cantstop.lua open.
- Add the following code at the bottom of the file, after the on.paint function:
function on.arrowKey(key)
message = "Arrow: " .. key
screen:invalidate()
end
What does this code do?
- on.arrowKey(key) is called whenever you press an arrow key. The key parameter is a string: "up", "down", "left", or "right".
- message = "Arrow: " .. key updates the text variable. The .. operator concatenates strings in Lua.
- screen:invalidate() tells the calculator to redraw the screen, which triggers on.paint again with the updated message.
- Save the file with Ctrl+S.
- Switch to the luna-web tab and upload your updated cantstop.lua file again to generate a fresh cantstop.tns.
- Switch to TI-Nspire CX II Connect and transfer the new cantstop.tns to your calculator using the same Transfer File flow as before.
- On your calculator, open the transferred file.
- Press each of the four arrow keys on your calculator.
Each time you press an arrow key, the text on screen should change to show which key you pressed (for example, "Arrow: up" or "Arrow: left").
Text not changing when you press arrow keys?
Make sure the on.arrowKey function is outside the on.paint function, not nested inside it. Each function should be at the top level of your file.
Also check that you included screen:invalidate() inside on.arrowKey. Without it, the calculator does not know to redraw the screen with the new message.
help me debug my on.arrowKey handler on TI-Nspire
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete cantstop.lua file at this point:
platform.apilevel = '2.2'
local screen = platform.window
local message = "Hello, Can't Stop!"
function on.paint(gc)
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "r", 10)
gc:drawString(message, 10, 10, "top")
end
function on.arrowKey(key)
message = "Arrow: " .. key
screen:invalidate()
end
Your development workflow is locked in. Next up, you will use this same write-convert-transfer cycle to draw the full Can't Stop game board.
Draw the Can't Stop Board
Your test script confirms the development workflow is solid. Now it's time to build the actual game board that Can't Stop is played on.
The Can't Stop board has 11 columns numbered 2 through 12, each with a different height based on how likely that dice sum is to appear. Column 7 (the most common sum) is the tallest at 13 spaces, while columns 2 and 12 (the rarest sums) are only 3 spaces tall.
In this step, get ready to:
- Define the game data structures (columns, heights, player positions, and state variables).
- Render the 11-column board with labeled tracks and colored player markers.
- Add a dice display area at the bottom of the screen.
Set up the game data structures
Before you can draw anything, you need to define what the board looks like in code. This means storing the column numbers, their heights, and tracking where each player's markers sit.
- In your ChromeOS Text Editor, select all the existing code in cantstop.lua and delete it.
- Replace it with the following game constants and state variables:
platform.apilevel = '2.2'
local screen = platform.window
-- Game constants
local COLUMNS = {2,3,4,5,6,7,8,9,10,11,12}
local COL_HEIGHTS = {3,5,7,9,11,13,11,9,7,5,3}
local NUM_COLS = 11
-- Game state
local playerProgress = {}
local aiProgress = {}
local wonBy = {}
local runners = {}
local runnerCols = {}
local dice = {0,0,0,0}
local gameState = "player_roll"
local turnOwner = "player"
local message = "Press Enter to Roll"
local playerWins = 0
local aiWins = 0
-- Initialize all columns to zero progress
for i = 1, NUM_COLS do
playerProgress[i] = 0
aiProgress[i] = 0
wonBy[i] = nil
end
What does this code do?
This sets up the entire data model for Can't Stop.
- COL_HEIGHTS stores how tall each column is. Column 7 (index 6) is 13 spaces because the sum of 7 is the most common when rolling two dice.
- playerProgress and aiProgress track how far up each column each player has permanently advanced.
- wonBy records which player has claimed each column (first to reach the top wins it).
- runners and runnerCols track temporary progress within a single turn (lost if you bust).
- Save cantstop.lua in your text editor.
Write the board rendering function
The TI-Nspire calls on.paint(gc) whenever the screen needs redrawing. You'll use the graphics context gc to draw rectangles for each cell and text for the column labels.
- Below the initialization loop you just wrote, add the following on.paint function:
function on.paint(gc)
local W = screen:width()
local H = screen:height()
-- Light background
gc:setColorRGB(240, 240, 230)
gc:fillRect(0, 0, W, H)
-- Board layout calculations
local boardTop = 18
local boardBottom = H - 40
local boardHeight = boardBottom - boardTop
local colWidth = math.floor(W / (NUM_COLS + 1))
local startX = math.floor((W - colWidth * NUM_COLS) / 2)
-- Draw each column
for i = 1, NUM_COLS do
local colNum = COLUMNS[i]
local height = COL_HEIGHTS[i]
local x = startX + (i - 1) * colWidth
local cellH = math.floor(boardHeight / 13)
-- Column number label at top
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "b", 7)
local label = tostring(colNum)
local lw = gc:getStringWidth(label)
gc:drawString(label, x + math.floor((colWidth - lw) / 2), boardTop - 2, "top")
-- Draw cells in this column
for j = 1, height do
local cy = boardBottom - j * cellH
if wonBy[i] then
-- Won column: fill with winner's color
if wonBy[i] == "player" then
gc:setColorRGB(100, 150, 255)
else
gc:setColorRGB(255, 100, 100)
end
gc:fillRect(x + 2, cy + 1, colWidth - 4, cellH - 2)
else
-- Open cell: gray outline
gc:setColorRGB(200, 200, 200)
gc:drawRect(x + 2, cy + 1, colWidth - 4, cellH - 2)
end
end
-- Player progress marker (blue, left half of cell)
if not wonBy[i] and playerProgress[i] > 0 then
local py = boardBottom - playerProgress[i] * cellH
gc:setColorRGB(50, 100, 220)
gc:fillRect(x + 3, py + 2, math.floor(colWidth / 2) - 4, cellH - 4)
end
-- AI progress marker (red, right half of cell)
if not wonBy[i] and aiProgress[i] > 0 then
local ay = boardBottom - aiProgress[i] * cellH
gc:setColorRGB(220, 50, 50)
gc:fillRect(x + math.floor(colWidth / 2) + 1, ay + 2, math.floor(colWidth / 2) - 4, cellH - 4)
end
end
-- Message bar at bottom
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "r", 7)
gc:drawString(message, 2, H - 10, "top")
-- Score display at top right
gc:setFont("sansserif", "b", 7)
gc:setColorRGB(50, 100, 220)
gc:drawString("You:" .. playerWins, W - 70, 2, "top")
gc:setColorRGB(220, 50, 50)
gc:drawString("AI:" .. aiWins, W - 30, 2, "top")
end
What's happening here?
- screen:width() and screen:height() give you the 318x212 pixel scripting area. All drawing scales from these values.
- gc:fillRect draws a filled rectangle (for backgrounds, markers, won columns). gc:drawRect draws just the outline (for empty cells).
- The column loop calculates each cell's position from the bottom up. cellH divides the board height by 13 (the tallest column) so all columns align to the same grid.
- Player markers occupy the left half of a cell (blue). AI markers occupy the right half (red). This lets you see both players' progress in the same column.
- Save cantstop.lua.
- Transfer the file to your calculator using TI-Nspire CX II Connect at nspireconnect.ti.com.
- Open the document on your calculator. You should see 11 columns of gray outlined squares with numbers 2 through 12 labeled at the top, a "Press Enter to Roll" message at the bottom left, and a score display at the top right.
Don't see the board?
- If you see a blank screen, check that you have the closing end for the on.paint function and both for loops inside it.
- If you see an error about a nil value, make sure the initialization loop (for i = 1, NUM_COLS do) appears BEFORE the on.paint function.
- Double check that platform.apilevel = '2.2' is still the very first line of your file.
Help me debug my board rendering code.
Add the dice display area
Can't Stop uses four dice each turn. You need a dedicated area at the bottom of the screen to show the rolled values as white squares with numbers inside them.
- In cantstop.lua, find this line inside your on.paint function:
-- Message bar at bottom
gc:setColorRGB(0, 0, 0)
- Add the following dice display code ABOVE that line (between the column loop's closing end and the message bar):
-- Dice display
if dice[1] > 0 then
gc:setFont("sansserif", "b", 9)
for i = 1, 4 do
local dx = math.floor(W / 2) - 60 + (i - 1) * 30
local dy = H - 36
-- White square for each die
gc:setColorRGB(255, 255, 255)
gc:fillRect(dx, dy, 24, 24)
gc:setColorRGB(0, 0, 0)
gc:drawRect(dx, dy, 24, 24)
-- Number centered on the die face
local ds = tostring(dice[i])
local dsw = gc:getStringWidth(ds)
gc:drawString(ds, dx + math.floor((24 - dsw) / 2), dy + 4, "top")
end
end
What does this code do?
- The if dice[1] > 0 guard means dice only appear after the first roll. Before that, the area stays clean.
- Each die is a 24x24 pixel white square with a black outline and the rolled number centered inside it.
- The four dice are spaced 30 pixels apart, centered horizontally on screen. They sit just above the message bar.
- Save cantstop.lua and transfer it to your calculator.
- Open the document on your calculator. You should see the full board with 11 labeled columns, a message at the bottom, and scores at the top right. The dice area won't show yet because dice[1] is still 0.
Seeing a syntax error after adding dice code?
- Make sure the dice block is placed between the column loop's closing end and the message bar section. It should NOT be inside the for i = 1, NUM_COLS do loop.
- Check that you have matching if ... end and for ... end pairs. The dice section adds one if and one for, each needing their own end.
Help me fix my dice display code.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete cantstop.lua file at this point:
platform.apilevel = '2.2'
local screen = platform.window
-- Game constants
local COLUMNS = {2,3,4,5,6,7,8,9,10,11,12}
local COL_HEIGHTS = {3,5,7,9,11,13,11,9,7,5,3}
local NUM_COLS = 11
-- Game state
local playerProgress = {}
local aiProgress = {}
local wonBy = {}
local runners = {}
local runnerCols = {}
local dice = {0,0,0,0}
local gameState = "player_roll"
local turnOwner = "player"
local message = "Press Enter to Roll"
local playerWins = 0
local aiWins = 0
for i = 1, NUM_COLS do
playerProgress[i] = 0
aiProgress[i] = 0
wonBy[i] = nil
end
function on.paint(gc)
local W = screen:width()
local H = screen:height()
gc:setColorRGB(240, 240, 230)
gc:fillRect(0, 0, W, H)
local boardTop = 18
local boardBottom = H - 40
local boardHeight = boardBottom - boardTop
local colWidth = math.floor(W / (NUM_COLS + 1))
local startX = math.floor((W - colWidth * NUM_COLS) / 2)
for i = 1, NUM_COLS do
local colNum = COLUMNS[i]
local height = COL_HEIGHTS[i]
local x = startX + (i - 1) * colWidth
local cellH = math.floor(boardHeight / 13)
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "b", 7)
local label = tostring(colNum)
local lw = gc:getStringWidth(label)
gc:drawString(label, x + math.floor((colWidth - lw) / 2), boardTop - 2, "top")
for j = 1, height do
local cy = boardBottom - j * cellH
if wonBy[i] then
if wonBy[i] == "player" then
gc:setColorRGB(100, 150, 255)
else
gc:setColorRGB(255, 100, 100)
end
gc:fillRect(x + 2, cy + 1, colWidth - 4, cellH - 2)
else
gc:setColorRGB(200, 200, 200)
gc:drawRect(x + 2, cy + 1, colWidth - 4, cellH - 2)
end
end
if not wonBy[i] and playerProgress[i] > 0 then
local py = boardBottom - playerProgress[i] * cellH
gc:setColorRGB(50, 100, 220)
gc:fillRect(x + 3, py + 2, math.floor(colWidth / 2) - 4, cellH - 4)
end
if not wonBy[i] and aiProgress[i] > 0 then
local ay = boardBottom - aiProgress[i] * cellH
gc:setColorRGB(220, 50, 50)
gc:fillRect(x + math.floor(colWidth / 2) + 1, ay + 2, math.floor(colWidth / 2) - 4, cellH - 4)
end
end
-- Dice display
if dice[1] > 0 then
gc:setFont("sansserif", "b", 9)
for i = 1, 4 do
local dx = math.floor(W / 2) - 60 + (i - 1) * 30
local dy = H - 36
gc:setColorRGB(255, 255, 255)
gc:fillRect(dx, dy, 24, 24)
gc:setColorRGB(0, 0, 0)
gc:drawRect(dx, dy, 24, 24)
local ds = tostring(dice[i])
local dsw = gc:getStringWidth(ds)
gc:drawString(ds, dx + math.floor((24 - dsw) / 2), dy + 4, "top")
end
end
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "r", 7)
gc:drawString(message, 2, H - 10, "top")
gc:setFont("sansserif", "b", 7)
gc:setColorRGB(50, 100, 220)
gc:drawString("You:" .. playerWins, W - 70, 2, "top")
gc:setColorRGB(220, 50, 50)
gc:drawString("AI:" .. aiWins, W - 30, 2, "top")
end
Your board is drawn and ready to play on. Next up, you'll add dice rolling and let the player choose how to pair four dice into two sums.
Implement Dice Rolling and Pairing Selection
Your board looks great on the calculator. Now it is time to make it interactive.
In Can't Stop, every turn starts with rolling four dice and choosing how to pair them into two sums. Each pairing determines which columns you advance in. In this step, you will build the dice rolling logic, display the possible pairings, and let the player select one using arrow keys.
In this step, get ready to:
- Roll four dice and compute all possible pairings.
- Display pairings on screen and let the player select one with arrow keys.
- Validate pairings against the 3-runner limit and detect busts.
Implement dice rolling and compute pairings
When a player rolls four dice in Can't Stop, there are up to 3 distinct ways to split those dice into two pairs. Each pair produces a column sum (2 through 12) that tells the player which column to advance in.
- In your ChromeOS Text Editor, find the game state variables at the top of cantstop.lua.
- Find this line:
local dice = {0,0,0,0}
- Add these two new variables directly below it:
local pairings = {}
local selectedPairing = 1
What are these variables for?
The pairings table stores the possible ways to split four dice into two pairs. selectedPairing tracks which pairing the player is currently highlighting with the arrow keys.
- Now add the dice rolling and pairing computation functions. Find the initialization loop that ends with wonBy[i] = nil, then add the following code below its closing end:
local function rollDice()
for i = 1, 4 do
dice[i] = math.random(1, 6)
end
end
local function computePairings()
pairings = {}
local d = dice
local combos = {
{d[1]+d[2], d[3]+d[4]},
{d[1]+d[3], d[2]+d[4]},
{d[1]+d[4], d[2]+d[3]}
}
local seen = {}
for _, pair in ipairs(combos) do
local a, b = math.min(pair[1], pair[2]), math.max(pair[1], pair[2])
local key = a .. "," .. b
if not seen[key] then
seen[key] = true
table.insert(pairings, {pair[1], pair[2]})
end
end
end
What does this code do?
The rollDice() function assigns a random value between 1 and 6 to each of the four dice using math.random(1, 6).
The computePairings() function generates all 3 ways to split 4 dice into 2 pairs: (1+2, 3+4), (1+3, 2+4), and (1+4, 2+3). The seen table deduplicates pairings that produce the same two sums, so the player never sees redundant options.
- Save cantstop.lua.
You will not see a change on the calculator yet. The next chunk wires these functions into the pairing display and input handlers.
Let the player select a pairing with arrow keys
After rolling, the player needs to see the available pairings on screen and cycle through them. You will add an arrow key handler and a rendering section that highlights the currently selected option.
- Add the arrow key handler at the bottom of cantstop.lua (after the closing end of on.paint) by copying this code:
function on.arrowKey(key)
if gameState == "player_select" then
if key == "up" then
selectedPairing = selectedPairing - 1
if selectedPairing < 1 then selectedPairing = #pairings end
elseif key == "down" then
selectedPairing = selectedPairing + 1
if selectedPairing > #pairings then selectedPairing = 1 end
end
screen:invalidate()
end
end
What does this code do?
This handler only responds when the game is in the player_select state. Pressing up or down cycles selectedPairing through the list, wrapping around at both ends.
Calling screen:invalidate() tells the calculator to redraw the screen, which updates the highlight.
- Now add the pairing display to your on.paint function. Inside on.paint, find the dice display block that starts with if dice[1] > 0 then and ends with its closing end.
- Add the following code directly below that closing end:
if gameState == "player_select" then
gc:setFont("sansserif", "r", 7)
for i, pair in ipairs(pairings) do
local py = H - 38 + (i - 1) * 12
local valid = isPairingValid(pair)
if i == selectedPairing then
gc:setColorRGB(0, 0, 180)
gc:drawString("> ", 2, py, "top")
else
gc:setColorRGB(80, 80, 80)
end
if not valid then gc:setColorRGB(180, 180, 180) end
gc:drawString(tostring(pair[1]) .. " + " .. tostring(pair[2]), 12, py, "top")
end
end
What does this rendering code do?
This block only draws when the game is in the player_select state. It loops through each pairing and draws it as text like 7 + 9.
The currently selected pairing gets a > arrow in blue. Invalid pairings are drawn in a light gray so the player knows they cannot choose them.
- Save cantstop.lua.
You still will not see changes on the calculator because the rendering calls isPairingValid which does not exist yet. The next substep adds validation and the enter key handler that ties everything together.
Validate pairings and detect busts
In Can't Stop, a player can only have runners in 3 columns at once. If a pairing would require a 4th column, it is invalid. If every pairing is invalid after a roll, the player busts and loses all progress from that turn.
- Add the validation functions below computePairings() (and above on.paint) by copying this code:
local function isPairingValid(pair)
local tempCols = {}
for _, c in ipairs(runnerCols) do
tempCols[c] = true
end
local newCols = 0
for _, sum in ipairs(pair) do
if sum >= 2 and sum <= 12 then
local colIdx = sum - 1
if not wonBy[colIdx] then
local found = false
for _, c in ipairs(runnerCols) do
if c == sum then found = true break end
end
if not found and not tempCols[sum] then
newCols = newCols + 1
tempCols[sum] = true
end
end
end
end
return (#runnerCols + newCols) <= 3
end
local function anyPairingValid()
for _, pair in ipairs(pairings) do
if isPairingValid(pair) then return true end
end
return false
end
What does this code do?
The isPairingValid() function checks whether choosing a specific pairing would push the player past the 3-runner limit. It counts how many new columns the pairing would require and returns true only if the total stays at 3 or fewer.
The anyPairingValid() function checks all available pairings. If none are valid, the player has busted.
- Now add the enter key handler that triggers a dice roll and transitions the game state. Add this below your on.arrowKey function at the bottom of the file:
function on.enterKey()
if gameState == "player_roll" then
rollDice()
computePairings()
if anyPairingValid() then
gameState = "player_select"
selectedPairing = 1
message = "Select pairing (arrows), Enter to confirm"
else
message = "No valid moves - BUST!"
end
screen:invalidate()
end
end
What does this code do?
When the player presses Enter during the player_roll state, the handler rolls all four dice and computes the available pairings.
If at least one valid pairing exists, the game transitions to player_select so the player can choose. If no pairing is valid, the player immediately busts and sees a message.
- Also remove the -- Dice display comment line above if dice[1] > 0 then in your on.paint function. It is no longer needed since the dice display section is now obvious from context.
- Save cantstop.lua.
- Transfer the updated file to your calculator using TI-Nspire CX II Connect.
- Open the .tns file on your calculator and press Enter. You should see four dice appear with random values, and a list of pairings (up to 3 options) on the left side of the screen.
- Press the up and down arrow keys. The > indicator should move between pairings.
Not seeing pairings after pressing Enter?
Make sure isPairingValid and anyPairingValid are defined above on.paint. In Lua, functions must be declared before they are called.
Check that the pairing display block is inside the on.paint function, not outside it. It must appear before the function's final end.
Help me debug why pairings don't appear after I press Enter on my TI-Nspire.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete cantstop.lua at this point:
platform.apilevel = '2.2'
local screen = platform.window
-- Game constants
local COLUMNS = {2,3,4,5,6,7,8,9,10,11,12}
local COL_HEIGHTS = {3,5,7,9,11,13,11,9,7,5,3}
local NUM_COLS = 11
-- Game state
local playerProgress = {}
local aiProgress = {}
local wonBy = {}
local runners = {}
local runnerCols = {}
local dice = {0,0,0,0}
local pairings = {}
local selectedPairing = 1
local gameState = "player_roll"
local turnOwner = "player"
local message = "Press Enter to Roll"
local playerWins = 0
local aiWins = 0
for i = 1, NUM_COLS do
playerProgress[i] = 0
aiProgress[i] = 0
wonBy[i] = nil
end
local function rollDice()
for i = 1, 4 do
dice[i] = math.random(1, 6)
end
end
local function computePairings()
pairings = {}
local d = dice
local combos = {
{d[1]+d[2], d[3]+d[4]},
{d[1]+d[3], d[2]+d[4]},
{d[1]+d[4], d[2]+d[3]}
}
local seen = {}
for _, pair in ipairs(combos) do
local a, b = math.min(pair[1], pair[2]), math.max(pair[1], pair[2])
local key = a .. "," .. b
if not seen[key] then
seen[key] = true
table.insert(pairings, {pair[1], pair[2]})
end
end
end
local function isPairingValid(pair)
local tempCols = {}
for _, c in ipairs(runnerCols) do
tempCols[c] = true
end
local newCols = 0
for _, sum in ipairs(pair) do
if sum >= 2 and sum <= 12 then
local colIdx = sum - 1
if not wonBy[colIdx] then
local found = false
for _, c in ipairs(runnerCols) do
if c == sum then found = true break end
end
if not found and not tempCols[sum] then
newCols = newCols + 1
tempCols[sum] = true
end
end
end
end
return (#runnerCols + newCols) <= 3
end
local function anyPairingValid()
for _, pair in ipairs(pairings) do
if isPairingValid(pair) then return true end
end
return false
end
function on.paint(gc)
local W = screen:width()
local H = screen:height()
gc:setColorRGB(240, 240, 230)
gc:fillRect(0, 0, W, H)
local boardTop = 18
local boardBottom = H - 40
local boardHeight = boardBottom - boardTop
local colWidth = math.floor(W / (NUM_COLS + 1))
local startX = math.floor((W - colWidth * NUM_COLS) / 2)
for i = 1, NUM_COLS do
local colNum = COLUMNS[i]
local height = COL_HEIGHTS[i]
local x = startX + (i - 1) * colWidth
local cellH = math.floor(boardHeight / 13)
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "b", 7)
local label = tostring(colNum)
local lw = gc:getStringWidth(label)
gc:drawString(label, x + math.floor((colWidth - lw) / 2), boardTop - 2, "top")
for j = 1, height do
local cy = boardBottom - j * cellH
if wonBy[i] then
if wonBy[i] == "player" then
gc:setColorRGB(100, 150, 255)
else
gc:setColorRGB(255, 100, 100)
end
gc:fillRect(x + 2, cy + 1, colWidth - 4, cellH - 2)
else
gc:setColorRGB(200, 200, 200)
gc:drawRect(x + 2, cy + 1, colWidth - 4, cellH - 2)
end
end
if not wonBy[i] and playerProgress[i] > 0 then
local py = boardBottom - playerProgress[i] * cellH
gc:setColorRGB(50, 100, 220)
gc:fillRect(x + 3, py + 2, math.floor(colWidth / 2) - 4, cellH - 4)
end
if not wonBy[i] and aiProgress[i] > 0 then
local ay = boardBottom - aiProgress[i] * cellH
gc:setColorRGB(220, 50, 50)
gc:fillRect(x + math.floor(colWidth / 2) + 1, ay + 2, math.floor(colWidth / 2) - 4, cellH - 4)
end
end
if dice[1] > 0 then
gc:setFont("sansserif", "b", 9)
for i = 1, 4 do
local dx = math.floor(W / 2) - 60 + (i - 1) * 30
local dy = H - 36
gc:setColorRGB(255, 255, 255)
gc:fillRect(dx, dy, 24, 24)
gc:setColorRGB(0, 0, 0)
gc:drawRect(dx, dy, 24, 24)
local ds = tostring(dice[i])
local dsw = gc:getStringWidth(ds)
gc:drawString(ds, dx + math.floor((24 - dsw) / 2), dy + 4, "top")
end
end
if gameState == "player_select" then
gc:setFont("sansserif", "r", 7)
for i, pair in ipairs(pairings) do
local py = H - 38 + (i - 1) * 12
local valid = isPairingValid(pair)
if i == selectedPairing then
gc:setColorRGB(0, 0, 180)
gc:drawString("> ", 2, py, "top")
else
gc:setColorRGB(80, 80, 80)
end
if not valid then gc:setColorRGB(180, 180, 180) end
gc:drawString(tostring(pair[1]) .. " + " .. tostring(pair[2]), 12, py, "top")
end
end
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "r", 7)
gc:drawString(message, 2, H - 10, "top")
gc:setFont("sansserif", "b", 7)
gc:setColorRGB(50, 100, 220)
gc:drawString("You:" .. playerWins, W - 70, 2, "top")
gc:setColorRGB(220, 50, 50)
gc:drawString("AI:" .. aiWins, W - 30, 2, "top")
end
function on.enterKey()
if gameState == "player_roll" then
rollDice()
computePairings()
if anyPairingValid() then
gameState = "player_select"
selectedPairing = 1
message = "Select pairing (arrows), Enter to confirm"
else
message = "No valid moves - BUST!"
end
screen:invalidate()
end
end
function on.arrowKey(key)
if gameState == "player_select" then
if key == "up" then
selectedPairing = selectedPairing - 1
if selectedPairing < 1 then selectedPairing = #pairings end
elseif key == "down" then
selectedPairing = selectedPairing + 1
if selectedPairing > #pairings then selectedPairing = 1 end
end
screen:invalidate()
end
end
You can now roll dice, see the possible pairings, and cycle through them with arrow keys. Next up, you will build the turn logic so that selecting a pairing actually advances runners on the board.
Build the Turn Logic
You can roll dice and select pairings, but nothing actually happens when you confirm your choice.
In Can't Stop, the core tension is a push-your-luck decision: after each successful roll, do you lock in your progress or risk losing everything for a chance to advance further? This step brings that decision to life.
In this step, get ready to:
- Implement the runner system that tracks temporary progress during a turn.
- Add the stop or continue choice that makes each roll a meaningful decision.
- Implement column claiming and the win condition that ends the game.
Implement the runner system
Runners are temporary markers that track your progress during a single turn. If you stop, runners become permanent progress. If you bust, they vanish.
- In cantstop.lua, find the isPairingValid function. Add this new applyPairing function directly below it (before anyPairingValid):
local function applyPairing(pair)
for _, sum in ipairs(pair) do
if sum >= 2 and sum <= 12 then
local colIdx = sum - 1
if not wonBy[colIdx] then
local found = false
for _, c in ipairs(runnerCols) do
if c == sum then found = true break end
end
if not found then
-- New runner in this column
table.insert(runnerCols, sum)
runners[sum] = 0
end
-- Advance the runner one space
runners[sum] = (runners[sum] or 0) + 1
end
end
end
end
What does this code do?
For each sum in the selected pairing, applyPairing checks if that column is still in play (not already won).
If the column doesn't have a runner yet, it adds one to runnerCols and initializes its position at 0. Then it advances the runner by 1 space. The 3-runner limit is enforced by isPairingValid before this function is ever called.
- Now add the runner rendering code to your on.paint function. Find the block that draws AI progress (the if not wonBy[i] and aiProgress[i] > 0 section). Add this code directly below its closing end:
-- Draw runners as white/hollow squares
if runners[colNum] and runners[colNum] > 0 then
local progress = (turnOwner == "player") and playerProgress[i] or aiProgress[i]
local ry = boardBottom - (progress + runners[colNum]) * cellH
gc:setColorRGB(255, 255, 255)
gc:fillRect(x + 4, ry + 3, colWidth - 8, cellH - 6)
gc:setColorRGB(0, 0, 0)
gc:drawRect(x + 4, ry + 3, colWidth - 8, cellH - 6)
end
What does this code do?
This draws runners as white squares with a black outline. They sit above the player's permanent progress, so you can see exactly how far your temporary advance goes.
The distinct look (white/hollow vs solid blue/red) makes it clear which progress is permanent and which is at risk.
- Now wire up the pairing confirmation. In your on.enterKey function, find the closing end of the if gameState == "player_roll" block (just before the final end of the function). Add this branch below it:
elseif gameState == "player_select" then
if isPairingValid(pairings[selectedPairing]) then
applyPairing(pairings[selectedPairing])
gameState = "player_decide"
message = "Enter=Roll Again, Tab=Stop"
screen:invalidate()
end
What does this code do?
When the player presses Enter while viewing pairings, this confirms their selection. It validates the pairing one more time, applies it (advancing runners), then switches to the player_decide state where the player chooses to roll again or stop.
- Save cantstop.lua and transfer to your calculator.
- Press Enter to roll, select a pairing with arrows, then press Enter to confirm. You should see a white/hollow square appear in the column(s) matching your pairing sums.
Don't see a white runner marker?
- Make sure the runner rendering code is inside the column drawing loop (the for i = 1, NUM_COLS do block), not outside it.
- Check that the elseif gameState == "player_select" branch is properly connected to the existing if gameState == "player_roll" block with no extra end between them.
- Confirm that applyPairing is defined before anyPairingValid in the file. Lua reads top-to-bottom, so function order matters.
Help me debug why my runner markers aren't appearing after I confirm a pairing.
Add the stop or continue choice
Right now, pressing Enter after confirming a pairing shows the message but you have no way to stop or handle busts. You need two functions: one to save progress when you stop, and one to clear everything when you bust.
- In cantstop.lua, find the anyPairingValid function. Add the following code below it (before on.paint). Start with the forward declarations and the stopTurn function:
local stopTurn, bustTurn
stopTurn = function()
-- Convert runners into permanent progress
local progress = (turnOwner == "player") and playerProgress or aiProgress
for _, col in ipairs(runnerCols) do
local colIdx = col - 1
progress[colIdx] = progress[colIdx] + runners[col]
-- Check if column is claimed
if progress[colIdx] >= COL_HEIGHTS[colIdx] then
progress[colIdx] = COL_HEIGHTS[colIdx]
wonBy[colIdx] = turnOwner
if turnOwner == "player" then
playerWins = playerWins + 1
else
aiWins = aiWins + 1
end
end
end
runners = {}
runnerCols = {}
-- Check win condition (first to claim 3 columns)
if playerWins >= 3 then
winner = "player"
gameState = "gameover"
elseif aiWins >= 3 then
winner = "ai"
gameState = "gameover"
else
if turnOwner == "player" then
turnOwner = "ai"
gameState = "ai_turn"
message = "AI is thinking..."
else
turnOwner = "player"
gameState = "player_roll"
message = "Press Enter to Roll"
end
end
end
What does this code do?
The forward declaration local stopTurn, bustTurn tells Lua these variables will hold functions. This lets stopTurn and bustTurn reference each other even though only one can be defined first.
When the player stops, stopTurn converts each runner's position into permanent progress. If that progress reaches the top of a column, the column is claimed. If either side claims 3 columns, the game ends. Otherwise, it switches turns.
- Add the bustTurn function directly below stopTurn:
bustTurn = function()
-- Lose all runner progress for this turn
runners = {}
runnerCols = {}
if turnOwner == "player" then
turnOwner = "ai"
gameState = "ai_turn"
message = "You busted! AI's turn..."
else
turnOwner = "player"
gameState = "player_roll"
message = "AI busted! Press Enter to Roll"
end
end
What does this code do?
When a player busts (no valid pairing exists after a roll), all runner progress is wiped. The runners and runnerCols tables are emptied with no progress saved. Then the turn switches to the other player.
- Add the on.tabKey handler after your on.enterKey function (before on.arrowKey):
function on.tabKey()
if gameState == "player_decide" then
stopTurn()
screen:invalidate()
end
end
What does this code do?
Pressing Tab during the player_decide state locks in your progress by calling stopTurn(). This is the "I'll take my winnings" moment in the push-your-luck decision.
- Now update on.enterKey to handle rolling again and busting. Add this elseif branch after the player_select branch you added earlier:
elseif gameState == "player_decide" then
-- Roll again
rollDice()
computePairings()
if anyPairingValid() then
gameState = "player_select"
selectedPairing = 1
message = "Select pairing (arrows), Enter to confirm"
else
message = "No valid moves - BUST!"
bustTurn()
end
screen:invalidate()
- Also update the bust case in the existing player_roll block. Find this line:
message = "No valid moves - BUST!"
- Add bustTurn() on the line directly below it, so both lines together look like this:
message = "No valid moves - BUST!"
bustTurn()
- Save cantstop.lua and transfer to your calculator.
- Play a full turn: roll dice, confirm a pairing (runners appear), then press Tab to stop. Your runners should convert into permanent blue progress markers.
- Try rolling multiple times before stopping. If all your pairings become invalid, you should see the bust message and lose your runners.
Tab key not saving progress?
- Make sure on.tabKey is a separate function defined outside of on.enterKey. It should be at the same indentation level as the other event handlers.
- Confirm the forward declaration local stopTurn, bustTurn appears before both function bodies. Without it, Lua cannot find stopTurn when on.tabKey calls it.
- Check that your gameState actually reaches "player_decide". If the player_select branch has a typo, Tab will never trigger.
Help me figure out why pressing Tab doesn't save my runner progress.
Implement column claiming and game end
Your turn logic works, but the game never actually ends. You need a way to detect when someone has claimed 3 columns, display the winner, and let the player restart.
- First, add a winner variable to your game state declarations at the top of the file. Find the line that says local message = "Press Enter to Roll" and add this directly below it:
local winner = nil
- Next, replace the bare initialization loop at the top of your file with an initGame() function. Find these lines:
for i = 1, NUM_COLS do
playerProgress[i] = 0
aiProgress[i] = 0
wonBy[i] = nil
end
- Replace that entire block with the initGame function and a call to it:
local function initGame()
playerProgress = {}
aiProgress = {}
wonBy = {}
runners = {}
runnerCols = {}
for i = 1, NUM_COLS do
playerProgress[i] = 0
aiProgress[i] = 0
wonBy[i] = nil
end
dice = {0,0,0,0}
pairings = {}
selectedPairing = 1
gameState = "player_roll"
turnOwner = "player"
message = "Press Enter to Roll"
winner = nil
playerWins = 0
aiWins = 0
end
initGame()
What does this code do?
The initGame() function resets every piece of game state to its starting values. This lets you restart a fresh game after someone wins without reloading the script.
Calling initGame() immediately after defining it sets up the initial state when the script first loads.
- Now add the game over screen to your on.paint function. Find the very last line before the function's closing end (after the score display). Add this block:
-- Game over overlay
if gameState == "gameover" then
gc:setColorRGB(255, 255, 255)
gc:fillRect(math.floor(W/4), math.floor(H/3), math.floor(W/2), 40)
gc:setColorRGB(0, 0, 0)
gc:drawRect(math.floor(W/4), math.floor(H/3), math.floor(W/2), 40)
gc:setFont("sansserif", "b", 10)
local msg = (winner == "player") and "YOU WIN!" or "AI WINS!"
local mw = gc:getStringWidth(msg)
gc:drawString(msg, math.floor((W - mw) / 2), math.floor(H/3) + 8, "top")
gc:setFont("sansserif", "r", 7)
local sub = "Press Enter to play again"
local sw = gc:getStringWidth(sub)
gc:drawString(sub, math.floor((W - sw) / 2), math.floor(H/3) + 24, "top")
end
What does this code do?
This draws a centered white rectangle overlay with the win/loss message and a prompt to restart. It only appears when gameState is "gameover", which stopTurn sets when either side reaches 3 claimed columns.
- Finally, add the game over handler to the top of your on.enterKey function. Find the opening line function on.enterKey() and add this block immediately below it (before the existing if gameState == "player_roll" check):
if gameState == "gameover" then
initGame()
screen:invalidate()
return
end
What does this code do?
When the game is over, pressing Enter calls initGame() to reset everything and start fresh. The return prevents the rest of the Enter key logic from running.
- Save cantstop.lua and transfer to your calculator.
- Play a full game. Roll, select pairings, advance runners, press Tab to stop. When your permanent progress (blue) reaches the top of a column, that column fills entirely with your color.
- Keep playing until you claim 3 columns. You should see the "YOU WIN!" overlay appear with a prompt to press Enter to play again.
Test claiming quickly
Target columns 2 or 12 (they only have 3 spaces). You can claim them in just a couple of turns, making it fast to verify the win condition works.
Game not ending when you claim 3 columns?
- Check that winner is declared as a variable at the top of your file (local winner = nil). Without it, the gameover overlay condition fails.
- Confirm initGame() is defined before on.enterKey in the file. If on.enterKey calls initGame() but it is not defined yet, you will get an error.
- Make sure the gameover check is the FIRST if inside on.enterKey, before the player_roll check. Otherwise the restart logic never runs.
Help me debug why my Can't Stop game doesn't show the win screen after claiming 3 columns.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete cantstop.lua file at this point:
platform.apilevel = '2.2'
local screen = platform.window
-- Game constants
local COLUMNS = {2,3,4,5,6,7,8,9,10,11,12}
local COL_HEIGHTS = {3,5,7,9,11,13,11,9,7,5,3}
local NUM_COLS = 11
-- Game state
local playerProgress = {}
local aiProgress = {}
local wonBy = {}
local runners = {}
local runnerCols = {}
local dice = {0,0,0,0}
local pairings = {}
local selectedPairing = 1
local gameState = "player_roll"
local turnOwner = "player"
local message = "Press Enter to Roll"
local winner = nil
local playerWins = 0
local aiWins = 0
local function initGame()
playerProgress = {}
aiProgress = {}
wonBy = {}
runners = {}
runnerCols = {}
for i = 1, NUM_COLS do
playerProgress[i] = 0
aiProgress[i] = 0
wonBy[i] = nil
end
dice = {0,0,0,0}
pairings = {}
selectedPairing = 1
gameState = "player_roll"
turnOwner = "player"
message = "Press Enter to Roll"
winner = nil
playerWins = 0
aiWins = 0
end
initGame()
local function rollDice()
for i = 1, 4 do
dice[i] = math.random(1, 6)
end
end
local function computePairings()
pairings = {}
local d = dice
local combos = {
{d[1]+d[2], d[3]+d[4]},
{d[1]+d[3], d[2]+d[4]},
{d[1]+d[4], d[2]+d[3]}
}
local seen = {}
for _, pair in ipairs(combos) do
local a, b = math.min(pair[1], pair[2]), math.max(pair[1], pair[2])
local key = a .. "," .. b
if not seen[key] then
seen[key] = true
table.insert(pairings, {pair[1], pair[2]})
end
end
end
local function isPairingValid(pair)
local tempCols = {}
for _, c in ipairs(runnerCols) do
tempCols[c] = true
end
local newCols = 0
for _, sum in ipairs(pair) do
if sum >= 2 and sum <= 12 then
local colIdx = sum - 1
if not wonBy[colIdx] then
local found = false
for _, c in ipairs(runnerCols) do
if c == sum then found = true break end
end
if not found and not tempCols[sum] then
newCols = newCols + 1
tempCols[sum] = true
end
end
end
end
return (#runnerCols + newCols) <= 3
end
local function applyPairing(pair)
for _, sum in ipairs(pair) do
if sum >= 2 and sum <= 12 then
local colIdx = sum - 1
if not wonBy[colIdx] then
local found = false
for _, c in ipairs(runnerCols) do
if c == sum then found = true break end
end
if not found then
table.insert(runnerCols, sum)
runners[sum] = 0
end
runners[sum] = (runners[sum] or 0) + 1
end
end
end
end
local function anyPairingValid()
for _, pair in ipairs(pairings) do
if isPairingValid(pair) then return true end
end
return false
end
local stopTurn, bustTurn
stopTurn = function()
local progress = (turnOwner == "player") and playerProgress or aiProgress
for _, col in ipairs(runnerCols) do
local colIdx = col - 1
progress[colIdx] = progress[colIdx] + runners[col]
if progress[colIdx] >= COL_HEIGHTS[colIdx] then
progress[colIdx] = COL_HEIGHTS[colIdx]
wonBy[colIdx] = turnOwner
if turnOwner == "player" then
playerWins = playerWins + 1
else
aiWins = aiWins + 1
end
end
end
runners = {}
runnerCols = {}
if playerWins >= 3 then
winner = "player"
gameState = "gameover"
elseif aiWins >= 3 then
winner = "ai"
gameState = "gameover"
else
if turnOwner == "player" then
turnOwner = "ai"
gameState = "ai_turn"
message = "AI is thinking..."
else
turnOwner = "player"
gameState = "player_roll"
message = "Press Enter to Roll"
end
end
end
bustTurn = function()
runners = {}
runnerCols = {}
if turnOwner == "player" then
turnOwner = "ai"
gameState = "ai_turn"
message = "You busted! AI's turn..."
else
turnOwner = "player"
gameState = "player_roll"
message = "AI busted! Press Enter to Roll"
end
end
function on.paint(gc)
local W = screen:width()
local H = screen:height()
gc:setColorRGB(240, 240, 230)
gc:fillRect(0, 0, W, H)
local boardTop = 18
local boardBottom = H - 40
local boardHeight = boardBottom - boardTop
local colWidth = math.floor(W / (NUM_COLS + 1))
local startX = math.floor((W - colWidth * NUM_COLS) / 2)
for i = 1, NUM_COLS do
local colNum = COLUMNS[i]
local height = COL_HEIGHTS[i]
local x = startX + (i - 1) * colWidth
local cellH = math.floor(boardHeight / 13)
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "b", 7)
local label = tostring(colNum)
local lw = gc:getStringWidth(label)
gc:drawString(label, x + math.floor((colWidth - lw) / 2), boardTop - 2, "top")
for j = 1, height do
local cy = boardBottom - j * cellH
if wonBy[i] then
if wonBy[i] == "player" then
gc:setColorRGB(100, 150, 255)
else
gc:setColorRGB(255, 100, 100)
end
gc:fillRect(x + 2, cy + 1, colWidth - 4, cellH - 2)
else
gc:setColorRGB(200, 200, 200)
gc:drawRect(x + 2, cy + 1, colWidth - 4, cellH - 2)
end
end
if not wonBy[i] and playerProgress[i] > 0 then
local py = boardBottom - playerProgress[i] * cellH
gc:setColorRGB(50, 100, 220)
gc:fillRect(x + 3, py + 2, math.floor(colWidth / 2) - 4, cellH - 4)
end
if not wonBy[i] and aiProgress[i] > 0 then
local ay = boardBottom - aiProgress[i] * cellH
gc:setColorRGB(220, 50, 50)
gc:fillRect(x + math.floor(colWidth / 2) + 1, ay + 2, math.floor(colWidth / 2) - 4, cellH - 4)
end
if runners[colNum] and runners[colNum] > 0 then
local progress = (turnOwner == "player") and playerProgress[i] or aiProgress[i]
local ry = boardBottom - (progress + runners[colNum]) * cellH
gc:setColorRGB(255, 255, 255)
gc:fillRect(x + 4, ry + 3, colWidth - 8, cellH - 6)
gc:setColorRGB(0, 0, 0)
gc:drawRect(x + 4, ry + 3, colWidth - 8, cellH - 6)
end
end
if dice[1] > 0 then
gc:setFont("sansserif", "b", 9)
for i = 1, 4 do
local dx = math.floor(W / 2) - 60 + (i - 1) * 30
local dy = H - 36
gc:setColorRGB(255, 255, 255)
gc:fillRect(dx, dy, 24, 24)
gc:setColorRGB(0, 0, 0)
gc:drawRect(dx, dy, 24, 24)
local ds = tostring(dice[i])
local dsw = gc:getStringWidth(ds)
gc:drawString(ds, dx + math.floor((24 - dsw) / 2), dy + 4, "top")
end
end
if gameState == "player_select" then
gc:setFont("sansserif", "r", 7)
for i, pair in ipairs(pairings) do
local py = H - 38 + (i - 1) * 12
local valid = isPairingValid(pair)
if i == selectedPairing then
gc:setColorRGB(0, 0, 180)
gc:drawString("> ", 2, py, "top")
else
gc:setColorRGB(80, 80, 80)
end
if not valid then gc:setColorRGB(180, 180, 180) end
gc:drawString(tostring(pair[1]) .. " + " .. tostring(pair[2]), 12, py, "top")
end
end
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "r", 7)
gc:drawString(message, 2, H - 10, "top")
gc:setFont("sansserif", "b", 7)
gc:setColorRGB(50, 100, 220)
gc:drawString("You:" .. playerWins, W - 70, 2, "top")
gc:setColorRGB(220, 50, 50)
gc:drawString("AI:" .. aiWins, W - 30, 2, "top")
if gameState == "gameover" then
gc:setColorRGB(255, 255, 255)
gc:fillRect(math.floor(W/4), math.floor(H/3), math.floor(W/2), 40)
gc:setColorRGB(0, 0, 0)
gc:drawRect(math.floor(W/4), math.floor(H/3), math.floor(W/2), 40)
gc:setFont("sansserif", "b", 10)
local msg = (winner == "player") and "YOU WIN!" or "AI WINS!"
local mw = gc:getStringWidth(msg)
gc:drawString(msg, math.floor((W - mw) / 2), math.floor(H/3) + 8, "top")
gc:setFont("sansserif", "r", 7)
local sub = "Press Enter to play again"
local sw = gc:getStringWidth(sub)
gc:drawString(sub, math.floor((W - sw) / 2), math.floor(H/3) + 24, "top")
end
end
function on.enterKey()
if gameState == "gameover" then
initGame()
screen:invalidate()
return
end
if gameState == "player_roll" then
rollDice()
computePairings()
if anyPairingValid() then
gameState = "player_select"
selectedPairing = 1
message = "Select pairing (arrows), Enter to confirm"
else
message = "No valid moves - BUST!"
bustTurn()
end
screen:invalidate()
elseif gameState == "player_select" then
if isPairingValid(pairings[selectedPairing]) then
applyPairing(pairings[selectedPairing])
gameState = "player_decide"
message = "Enter=Roll Again, Tab=Stop"
screen:invalidate()
end
elseif gameState == "player_decide" then
rollDice()
computePairings()
if anyPairingValid() then
gameState = "player_select"
selectedPairing = 1
message = "Select pairing (arrows), Enter to confirm"
else
message = "No valid moves - BUST!"
bustTurn()
end
screen:invalidate()
end
end
function on.tabKey()
if gameState == "player_decide" then
stopTurn()
screen:invalidate()
end
end
function on.arrowKey(key)
if gameState == "player_select" then
if key == "up" then
selectedPairing = selectedPairing - 1
if selectedPairing < 1 then selectedPairing = #pairings end
elseif key == "down" then
selectedPairing = selectedPairing + 1
if selectedPairing > #pairings then selectedPairing = 1 end
end
screen:invalidate()
end
end
Your turn logic is complete. You can roll, select pairings, advance runners, stop to save progress, bust and lose it all, claim columns, and win the game. Next up, you will build an AI opponent that plays against you using probability-based decisions.
Create the AI Opponent
Your player can now roll dice, pick pairings, push their luck, and claim columns. The board is missing one critical ingredient: someone to play against.
In this step, you will build an AI opponent that makes its own decisions about which pairings to choose and when to stop rolling. The AI uses a probability-based heuristic to decide how much risk to take on each turn.
In this step, get ready to:
- Implement the AI's turn loop with automatic pairing selection.
- Build a push-your-luck heuristic that decides when the AI stops rolling.
- Wire up the full game loop so turns alternate between you and the AI.
Implement the AI's turn loop
The AI needs to do the same things you do on your turn: roll four dice, pick the best pairing, and advance runners. The difference is that the AI makes these choices automatically using a scoring system.
- In cantstop.lua, find the closing end of the bustTurn function.
- Add the following aiTakeTurn function directly below it:
local function aiTakeTurn()
rollDice()
computePairings()
if not anyPairingValid() then
bustTurn()
return
end
local bestIdx = 1
local bestScore = -1
for i, pair in ipairs(pairings) do
if isPairingValid(pair) then
local score = 0
for _, sum in ipairs(pair) do
if sum >= 2 and sum <= 12 then
local colIdx = sum - 1
if not wonBy[colIdx] then
score = score + aiProgress[colIdx]
if sum >= 6 and sum <= 8 then score = score + 2 end
end
end
end
if score > bestScore then
bestScore = score
bestIdx = i
end
end
end
applyPairing(pairings[bestIdx])
local dangerScore = 0
for _, col in ipairs(runnerCols) do
local weight = math.abs(7 - col) + 1
dangerScore = dangerScore + (runners[col] or 0) * weight
end
if dangerScore >= 28 then
stopTurn()
else
gameState = "ai_rolling"
message = "AI rolls again..."
end
end
What does this code do?
This function handles one complete AI action (one roll and one pairing decision).
- The AI rolls four dice and checks if any valid pairing exists. If not, it busts immediately.
- It scores each valid pairing by adding the AI's existing progress in those columns, plus a +2 bonus for middle columns (6, 7, 8) since those are statistically easier to roll.
- After applying the best pairing, it calculates a "danger score" to decide whether to stop or roll again.
You won't see a change yet. The AI function exists but nothing calls it. The next chunk wires it up.
Build the push-your-luck heuristic
The danger score inside aiTakeTurn is based on a technique called the "Rule of 28." Each column advance is weighted by how hard that column is to roll. When the total weighted progress crosses a threshold of 28, the AI stops.
How does the danger score work?
The formula math.abs(7 - col) + 1 gives outer columns (like 2 and 12) a higher weight because they have fewer ways to be rolled. Column 7 has weight 1, column 6 and 8 have weight 2, and columns 2 and 12 have weight 6.
This means one step in column 12 adds as much danger as six steps in column 7. The AI takes more risk in easy columns and plays it safe in hard ones.
Now you need a way to trigger aiTakeTurn with a delay so the player can follow along. The TI-Nspire timer fires every 0.8 seconds, giving the AI a visible pause between rolls.
- Add the checkAI function directly below aiTakeTurn:
checkAI = function()
if gameState == "ai_turn" or gameState == "ai_rolling" then
timer.start(0.8)
end
end
What does checkAI do?
When the game state switches to ai_turn or ai_rolling, this function starts a periodic timer. Every 0.8 seconds, the on.timer() handler (already in your code) calls aiTakeTurn() to execute the AI's next action.
You won't see a change yet. The next chunk connects checkAI to the turn-switching logic.
Wire up the full game loop
Right now, stopTurn and bustTurn already switch the game state to ai_turn when the player finishes. But nothing triggers the timer afterwards. You need to call checkAI() at the end of both functions so the AI starts thinking.
- First, update the forward declaration near the top of your file. Find this line:
local stopTurn, bustTurn
- Replace it with this line to include checkAI in the forward declaration:
local stopTurn, bustTurn, checkAI
- Now scroll down to the stopTurn function. Find the very last end that closes the function. It currently looks like this:
message = "Press Enter to Roll"
end
end
end
- Add checkAI() before the final end, so the end of stopTurn now looks like this:
message = "Press Enter to Roll"
end
end
checkAI()
end
- Do the same for bustTurn. Find the closing end of that function. It currently looks like this:
message = "AI busted! Press Enter to Roll"
end
end
- Add checkAI() before the final end so it looks like this:
message = "AI busted! Press Enter to Roll"
end
checkAI()
end
Why add checkAI() to both functions?
Both stopTurn and bustTurn can switch the game to the AI's turn. By calling checkAI() at the end of each, you guarantee the timer starts regardless of whether the player stopped voluntarily or busted.
If the game just ended (someone won), gameState will be gameover and checkAI does nothing. The timer only starts when the state is ai_turn or ai_rolling.
- Save cantstop.lua.
- Transfer the file to your calculator using TI-Nspire CX II Connect.
- Open the .tns file on your calculator and play a full game. Take your turn, then watch the AI roll and make decisions with a brief pause between each action.
You should see the message bar display "AI is thinking..." when it is the AI's turn, and "AI rolls again..." when it continues. The AI's red markers advance on the board. When someone claims 3 columns, the win/loss screen appears.
AI never takes its turn?
- Make sure you added checkAI() inside BOTH stopTurn and bustTurn. If either is missing, the timer never starts after that type of turn end.
- Check that the forward declaration reads local stopTurn, bustTurn, checkAI. Without checkAI in the forward declaration, Lua treats it as a global and the reference from inside stopTurn may not resolve correctly.
- Verify that aiTakeTurn is defined BEFORE checkAI in the file. The on.timer() handler calls aiTakeTurn() so it must exist by the time the timer fires.
Help me debug why my AI opponent isn't taking its turn in Can't Stop.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
platform.apilevel = '2.2'
local screen = platform.window
-- Game constants
local COLUMNS = {2,3,4,5,6,7,8,9,10,11,12}
local COL_HEIGHTS = {3,5,7,9,11,13,11,9,7,5,3}
local NUM_COLS = 11
-- Game state
local playerProgress = {}
local aiProgress = {}
local wonBy = {}
local runners = {}
local runnerCols = {}
local dice = {0,0,0,0}
local pairings = {}
local selectedPairing = 1
local gameState = "player_roll"
local turnOwner = "player"
local message = "Press Enter to Roll"
local winner = nil
local playerWins = 0
local aiWins = 0
local function initGame()
playerProgress = {}
aiProgress = {}
wonBy = {}
runners = {}
runnerCols = {}
for i = 1, NUM_COLS do
playerProgress[i] = 0
aiProgress[i] = 0
wonBy[i] = nil
end
dice = {0,0,0,0}
pairings = {}
selectedPairing = 1
gameState = "player_roll"
turnOwner = "player"
message = "Press Enter to Roll"
winner = nil
playerWins = 0
aiWins = 0
end
initGame()
local function rollDice()
for i = 1, 4 do
dice[i] = math.random(1, 6)
end
end
local function computePairings()
pairings = {}
local d = dice
local combos = {
{d[1]+d[2], d[3]+d[4]},
{d[1]+d[3], d[2]+d[4]},
{d[1]+d[4], d[2]+d[3]}
}
local seen = {}
for _, pair in ipairs(combos) do
local a, b = math.min(pair[1], pair[2]), math.max(pair[1], pair[2])
local key = a .. "," .. b
if not seen[key] then
seen[key] = true
table.insert(pairings, {pair[1], pair[2]})
end
end
end
local function isPairingValid(pair)
local tempCols = {}
for _, c in ipairs(runnerCols) do
tempCols[c] = true
end
local newCols = 0
for _, sum in ipairs(pair) do
if sum >= 2 and sum <= 12 then
local colIdx = sum - 1
if not wonBy[colIdx] then
local found = false
for _, c in ipairs(runnerCols) do
if c == sum then found = true break end
end
if not found and not tempCols[sum] then
newCols = newCols + 1
tempCols[sum] = true
end
end
end
end
return (#runnerCols + newCols) <= 3
end
local function applyPairing(pair)
for _, sum in ipairs(pair) do
if sum >= 2 and sum <= 12 then
local colIdx = sum - 1
if not wonBy[colIdx] then
local found = false
for _, c in ipairs(runnerCols) do
if c == sum then found = true break end
end
if not found then
table.insert(runnerCols, sum)
runners[sum] = 0
end
runners[sum] = (runners[sum] or 0) + 1
end
end
end
end
local function anyPairingValid()
for _, pair in ipairs(pairings) do
if isPairingValid(pair) then return true end
end
return false
end
local stopTurn, bustTurn, checkAI
stopTurn = function()
local progress = (turnOwner == "player") and playerProgress or aiProgress
for _, col in ipairs(runnerCols) do
local colIdx = col - 1
progress[colIdx] = progress[colIdx] + runners[col]
if progress[colIdx] >= COL_HEIGHTS[colIdx] then
progress[colIdx] = COL_HEIGHTS[colIdx]
wonBy[colIdx] = turnOwner
if turnOwner == "player" then
playerWins = playerWins + 1
else
aiWins = aiWins + 1
end
end
end
runners = {}
runnerCols = {}
if playerWins >= 3 then
winner = "player"
gameState = "gameover"
elseif aiWins >= 3 then
winner = "ai"
gameState = "gameover"
else
if turnOwner == "player" then
turnOwner = "ai"
gameState = "ai_turn"
message = "AI is thinking..."
else
turnOwner = "player"
gameState = "player_roll"
message = "Press Enter to Roll"
end
end
checkAI()
end
bustTurn = function()
runners = {}
runnerCols = {}
if turnOwner == "player" then
turnOwner = "ai"
gameState = "ai_turn"
message = "You busted! AI's turn..."
else
turnOwner = "player"
gameState = "player_roll"
message = "AI busted! Press Enter to Roll"
end
checkAI()
end
local function aiTakeTurn()
rollDice()
computePairings()
if not anyPairingValid() then
bustTurn()
return
end
local bestIdx = 1
local bestScore = -1
for i, pair in ipairs(pairings) do
if isPairingValid(pair) then
local score = 0
for _, sum in ipairs(pair) do
if sum >= 2 and sum <= 12 then
local colIdx = sum - 1
if not wonBy[colIdx] then
score = score + aiProgress[colIdx]
if sum >= 6 and sum <= 8 then score = score + 2 end
end
end
end
if score > bestScore then
bestScore = score
bestIdx = i
end
end
end
applyPairing(pairings[bestIdx])
local dangerScore = 0
for _, col in ipairs(runnerCols) do
local weight = math.abs(7 - col) + 1
dangerScore = dangerScore + (runners[col] or 0) * weight
end
if dangerScore >= 28 then
stopTurn()
else
gameState = "ai_rolling"
message = "AI rolls again..."
end
end
checkAI = function()
if gameState == "ai_turn" or gameState == "ai_rolling" then
timer.start(0.8)
end
end
function on.paint(gc)
local W = screen:width()
local H = screen:height()
gc:setColorRGB(240, 240, 230)
gc:fillRect(0, 0, W, H)
local boardTop = 18
local boardBottom = H - 40
local boardHeight = boardBottom - boardTop
local colWidth = math.floor(W / (NUM_COLS + 1))
local startX = math.floor((W - colWidth * NUM_COLS) / 2)
for i = 1, NUM_COLS do
local colNum = COLUMNS[i]
local height = COL_HEIGHTS[i]
local x = startX + (i - 1) * colWidth
local cellH = math.floor(boardHeight / 13)
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "b", 7)
local label = tostring(colNum)
local lw = gc:getStringWidth(label)
gc:drawString(label, x + math.floor((colWidth - lw) / 2), boardTop - 2, "top")
for j = 1, height do
local cy = boardBottom - j * cellH
if wonBy[i] then
if wonBy[i] == "player" then
gc:setColorRGB(100, 150, 255)
else
gc:setColorRGB(255, 100, 100)
end
gc:fillRect(x + 2, cy + 1, colWidth - 4, cellH - 2)
else
gc:setColorRGB(200, 200, 200)
gc:drawRect(x + 2, cy + 1, colWidth - 4, cellH - 2)
end
end
if not wonBy[i] and playerProgress[i] > 0 then
local py = boardBottom - playerProgress[i] * cellH
gc:setColorRGB(50, 100, 220)
gc:fillRect(x + 3, py + 2, math.floor(colWidth / 2) - 4, cellH - 4)
end
if not wonBy[i] and aiProgress[i] > 0 then
local ay = boardBottom - aiProgress[i] * cellH
gc:setColorRGB(220, 50, 50)
gc:fillRect(x + math.floor(colWidth / 2) + 1, ay + 2, math.floor(colWidth / 2) - 4, cellH - 4)
end
if runners[colNum] and runners[colNum] > 0 then
local progress = (turnOwner == "player") and playerProgress[i] or aiProgress[i]
local ry = boardBottom - (progress + runners[colNum]) * cellH
gc:setColorRGB(255, 255, 255)
gc:fillRect(x + 4, ry + 3, colWidth - 8, cellH - 6)
gc:setColorRGB(0, 0, 0)
gc:drawRect(x + 4, ry + 3, colWidth - 8, cellH - 6)
end
end
if dice[1] > 0 then
gc:setFont("sansserif", "b", 9)
for i = 1, 4 do
local dx = math.floor(W / 2) - 60 + (i - 1) * 30
local dy = H - 36
gc:setColorRGB(255, 255, 255)
gc:fillRect(dx, dy, 24, 24)
gc:setColorRGB(0, 0, 0)
gc:drawRect(dx, dy, 24, 24)
local ds = tostring(dice[i])
local dsw = gc:getStringWidth(ds)
gc:drawString(ds, dx + math.floor((24 - dsw) / 2), dy + 4, "top")
end
end
if gameState == "player_select" then
gc:setFont("sansserif", "r", 7)
for i, pair in ipairs(pairings) do
local py = H - 38 + (i - 1) * 12
local valid = isPairingValid(pair)
if i == selectedPairing then
gc:setColorRGB(0, 0, 180)
gc:drawString("> ", 2, py, "top")
else
gc:setColorRGB(80, 80, 80)
end
if not valid then gc:setColorRGB(180, 180, 180) end
gc:drawString(tostring(pair[1]) .. " + " .. tostring(pair[2]), 12, py, "top")
end
end
gc:setColorRGB(0, 0, 0)
gc:setFont("sansserif", "r", 7)
gc:drawString(message, 2, H - 10, "top")
gc:setFont("sansserif", "b", 7)
gc:setColorRGB(50, 100, 220)
gc:drawString("You:" .. playerWins, W - 70, 2, "top")
gc:setColorRGB(220, 50, 50)
gc:drawString("AI:" .. aiWins, W - 30, 2, "top")
if gameState == "gameover" then
gc:setColorRGB(255, 255, 255)
gc:fillRect(math.floor(W/4), math.floor(H/3), math.floor(W/2), 40)
gc:setColorRGB(0, 0, 0)
gc:drawRect(math.floor(W/4), math.floor(H/3), math.floor(W/2), 40)
gc:setFont("sansserif", "b", 10)
local msg = (winner == "player") and "YOU WIN!" or "AI WINS!"
local mw = gc:getStringWidth(msg)
gc:drawString(msg, math.floor((W - mw) / 2), math.floor(H/3) + 8, "top")
gc:setFont("sansserif", "r", 7)
local sub = "Press Enter to play again"
local sw = gc:getStringWidth(sub)
gc:drawString(sub, math.floor((W - sw) / 2), math.floor(H/3) + 24, "top")
end
end
function on.enterKey()
if gameState == "gameover" then
initGame()
screen:invalidate()
return
end
if gameState == "player_roll" then
rollDice()
computePairings()
if anyPairingValid() then
gameState = "player_select"
selectedPairing = 1
message = "Select pairing (arrows), Enter to confirm"
else
message = "No valid moves - BUST!"
bustTurn()
end
screen:invalidate()
elseif gameState == "player_select" then
if isPairingValid(pairings[selectedPairing]) then
applyPairing(pairings[selectedPairing])
gameState = "player_decide"
message = "Enter=Roll Again, Tab=Stop"
screen:invalidate()
end
elseif gameState == "player_decide" then
rollDice()
computePairings()
if anyPairingValid() then
gameState = "player_select"
selectedPairing = 1
message = "Select pairing (arrows), Enter to confirm"
else
message = "No valid moves - BUST!"
bustTurn()
end
screen:invalidate()
end
end
function on.tabKey()
if gameState == "player_decide" then
stopTurn()
screen:invalidate()
end
end
function on.arrowKey(key)
if gameState == "player_select" then
if key == "up" then
selectedPairing = selectedPairing - 1
if selectedPairing < 1 then selectedPairing = #pairings end
elseif key == "down" then
selectedPairing = selectedPairing + 1
if selectedPairing > #pairings then selectedPairing = 1 end
end
screen:invalidate()
end
end
function on.timer()
if gameState == "ai_turn" or gameState == "ai_rolling" then
aiTakeTurn()
screen:invalidate()
if gameState == "ai_rolling" then
else
timer.stop()
end
end
end
function on.getFocus()
screen:invalidate()
end
Secret mission
Add AI Difficulty Levels
Your AI opponent always plays the same way. What if you could choose how aggressive it is? In this secret mission, you'll add a title screen with three difficulty options that change how the AI decides when to stop rolling.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your Chromebook and calculator, so there are no ongoing costs. Decide whether to keep your game files, or delete them entirely.
Resources you used:
- cantstop.lua file on your Chromebook (ChromeOS Text Editor).
- .tns file on your TI-Nspire CX II CAS calculator.
Keep everything running
No action needed. Choose this if you want to keep playing Can't Stop on your calculator or continue editing the code.
- Your cantstop.lua stays in your ChromeOS Text Editor, ready for edits anytime.
- Your .tns file stays on your calculator. Open it from the calculator's home screen to play.
- Share the .tns file with friends who have TI-Nspire calculators by transferring it via USB.
Pause - I'll come back to this later
Since everything is stored as local files, there is nothing to pause or shut down. Your files stay where they are until you return.
- Your cantstop.lua file remains in your Chromebook's text editor. It uses negligible storage.
- The game on your calculator stays playable without any internet connection or running services.
Delete - I don't want to use this again
Remove all project files from your Chromebook and calculator.
- On your Chromebook, open the Files app and locate cantstop.lua. Right-click it and select Delete.
- On your TI-Nspire calculator, go to My Documents from the home screen. Navigate to the folder containing your Can't Stop .tns file. Press ctrl + menu to open the context menu, then select Delete.
That's a Wrap!
That's a Wrap!
Nice work! You just built a complete Can't Stop board game in Lua for your TI-Nspire CX II CAS, playable against an AI opponent right on your calculator.
You've learned how to:
- Implemented pixel-based graphics rendering for an 11-column game board on a 318x212 display using gc:fillRect, gc:drawRect, and gc:setColorRGB.
- Created an event-driven input system using on.arrowKey, on.enterKey, and on.tabKey to handle all game interaction.
- Designed an AI opponent with a probability-based push-your-luck heuristic (the Rule of 28) that decides when to stop rolling based on weighted danger scores.
- Secret Mission: Added a difficulty selection menu with three distinct AI personalities (Cautious, Normal, Reckless) that change the AI's risk threshold.
Ready to quiz yourself?