A little programming game

The kids recently got a little paper computer and, perhaps ill-advisedly, I've been building it on my touchscreen computer. The way I rationalize it is, the paper computer gives no feedback on whether a program works, and checking instructions seems like a tedious, error-prone task that is ideal to be automated away.
My app is "responsive", i.e. it adapts to the size of the screen. If you rotate your device, it adjusts gracefully. It's amazing how few lines of code it takes to obtain responsiveness if you build your "page" in an imperative way rather than atop layers of ostensibly "declarative" notation.
Unfortunately, in the process of making it responsive I found something missing in Carousel: a `car.resize` function akin to `love.resize` that is called when you rotate your device. So I had to ship a new version with this function. Upgrade to try out this program.
This is the largest program I've shown so far, and to keep it manageable I've split it up into 3 screens. Here's the first, copy it into a screen called 'rabbot':
ui_state = {} -- button state recreated each frame
-- level is 4x4 tiles
-- each tile is 2 digits, connects at most 2 sides
-- up=1 right=2 down=3 left=4
-- examples:
-- 12 from upper margin to right
-- 13 straight from top to bottom of tile
board = {
{0, 0, 0, 0},
{0, 0, 0, 0},
{0, 0, 0, 0},
{0, 0, 0, 0},
}
board_color = {0, 0.8, 0}
r = 4 -- road width
road_color = {0.4, 0, 0.4}
code = {} -- up to 20 steps ('forward', 'left' or 'right')
-- rabbot initially starts out outside the board, above the top-left tile, going down
rabbot_state = {x=1,y=0, dir=3, status=nil}
animation_frame_duration = 0.5 -- seconds
-- at most one tool will be dragged at a time
-- examples: {'board', 42}, {'code', 'forward'}, {'rabbot'}
drag = nil
-- 16x16 rabbot sprite authored elsewhere
rabbot = json.decode([=[
[[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,2,2,1,1,1,2,2,1,1,1],
[1,1,1,1,1,1,1,2,2,1,1,1,2,2,1,1],
[1,1,1,1,1,1,1,2,2,2,2,1,2,2,1,1],
[1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,1],
[1,1,1,1,1,1,1,1,1,2,1,2,2,1,2,1],
[1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,1],
[1,1,1,1,1,1,1,1,1,2,2,1,2,1,2,1],
[1,1,1,1,1,1,1,1,1,1,2,2,1,2,2,1],
[1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1],
[1,1,2,2,2,2,2,2,2,2,2,2,2,2,1,1],
[1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
[1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
[1,1,2,2,1,1,2,2,1,2,2,1,1,2,2,1],
[1,1,2,2,1,1,2,2,1,2,2,1,1,2,2,1],
[1,1,1,2,2,2,2,1,1,1,2,2,2,2,1,1]]
]=])
rabbot_palette = {board_color, {1,1,1}}
function car.draw()
ui_state.button_handlers = {}
if Safe_width > Safe_height then
draw_landscape()
else
draw_portrait()
end
draw_drag()
draw_rabbot()
draw_rabbot_path()
end
-- initialize viewport now and any time device rotates
function car.resize(w,h)
W = Safe_width-30*2 -- next/prev screen buttons
H = Safe_height - Menu_bottom
end
car.resize()
function draw_landscape()
-- width: 2 toolbar + 1 gutter + 4 board + 1 gutter + 1 toolbar + 1 gutter + 5 code = 15 units
-- height: 1 status + 4 code + 1 gutter + 1 run button = 7 units
side = floor(min(W/15, H/7))
local left = floor((Safe_width-side*15)/2)
local top = floor((Safe_height-side*7)/2)
top = top + side
draw_board_toolbar(left, top)
left = left + side*3
draw_board(left, top)
left = left + side*5
draw_code_toolbar(left, top)
left = left + side*2
draw_code(left, top)
end
function draw_portrait()
-- width: 2 toolbar + 1 gutter + 4 board + 1 drop zone = 8 units
-- height: 1 status + 4 board + 1 gutter + 4 code + 1 gutter + 1 run button = 12 units
side = floor(min(W/8, H/12))
local left = floor((Safe_width-side*8)/2)
local top = floor((Safe_height-side*12)/2)
top = top+side
draw_board_toolbar(left, top)
draw_board(left+side*3, top)
top = top+side*5
draw_code_toolbar(left, top)
draw_code(left+side*2, top)
end
function car.mouse_press(x,y, b)
rabbot_state.status = nil
rabbot_state.path = {}
rabbot_state.ip = nil
if mouse_press_consumed_by_any_button(ui_state, x,y, b) then
return
end
end
function car.mouse_release(x,y)
if drag == nil then return end
if drag[1] == 'board' then
local b = ui_state.board
local left, top = b.left, b.top
if x >= left and x < left+4*side
and y >= top and y < top+4*side then
board[floor((y-top)/side)+1][floor((x-left)/side)+1] = drag[2]
end
elseif drag[1] == 'code' then
local c = ui_state.code
local left, top = c.left, c.top
if x >= left and x < left+5*side
and y >= top and y < top+4*side then
local i = floor((y-top)/side)*5 + floor((x-left)/side)+1
if i <= #code or drag[2] == nil then
code[i] = drag[2]
else
table.insert(code, drag[2])
end end
elseif drag[1] == 'rabbot' then
drop_rabbot()
rabbot_state.path = {}
rabbot_state.ip = 1
rabbot_state.next_update = Current_time + animation_frame_duration
end
drag = nil
end
function car.update(dt)
step_rabbot()
end
function draw_drag()
if drag == nil then return end
local x,y = App.mouse_x()-side/2, App.mouse_y()-side/2
if drag[1] == 'board' then
draw_tile_bg(x, y)
draw_tile_fg(drag[2], x,y)
elseif drag[1] == 'code' then
draw_code_step('', drag[2], x,y)
end end
function draw_sprite(sprite, zoom, x,y, palette)
for j,row in ipairs(sprite) do
for i,cell in ipairs(row) do
color(unpack(palette[cell]))
rect('fill', x+(i-1)*zoom, y+(j-1)*zoom, zoom, zoom)
end end end
Here's the second screen, save it as `rabbot-draw`:
-- board
function draw_board_toolbar(left, top)
draw_tile_tool(0, left+side/2, top)
draw_tile_tool(13, left, top+side)
draw_tile_tool(24, left+side, top+side)
draw_tile_tool(23, left, top+2*side)
draw_tile_tool(34, left+side, top+2*side)
draw_tile_tool(12, left, top+3*side)
draw_tile_tool(14, left+side, top+3*side)
end
function draw_tile_tool(t, left, top)
local b = board_color
button(ui_state, t, {
x=left, y=top, w=side-1, h=side-1,
bg={r=b[1], g=b[2], b=b[3]},
icon = function(p)
draw_tile_fg(t, p.x, p.y)
end,
onpress1 = function()
drag = {'board', t}
end
})
end
function draw_board(left, top)
ui_state.board = {left=left, top=top}
for y,row in ipairs(board) do
for x,_ in ipairs(row) do
draw_tile_bg(left+(x-1)*side, top+(y-1)*side)
end end
for y,row in ipairs(board) do
for x,tile in ipairs(row) do
draw_tile_fg(tile, left+(x-1)*side, top+(y-1)*side)
end end
if rabbot_state.status then
color(0,0,0)
g.print(rabbot_state.status, left, top-side/2)
end
color(0,0,0)
g.print('board', left+2*side-App.width('board')/2, top+side*4)
end
function draw_tile_bg(x,y)
color(unpack(board_color))
rect('fill', x,y, side-1,side-1)
end
function draw_tile_fg(t, x,y)
if t == 0 then return end
color(unpack(road_color))
g.setLineWidth(r)
if t == 13 then
line(x+side/2, y, x+side/2, y+side)
elseif t == 24 then
line(x, y+side/2, x+side, y+side/2)
elseif t == 12 then
arc('line', 'open', x+side,y, side/2, pi/2, pi)
elseif t == 14 then
arc('line', 'open', x,y, side/2, 0,pi/2)
elseif t == 23 then
arc('line', 'open', x+side,y+side, side/2, pi, pi*3/2)
elseif t == 34 then
arc('line', 'open', x,y+side, side/2, pi*3/2, pi*2)
end end
-- code
function draw_code_toolbar(left, top)
draw_code_tool(nil, left, top)
draw_code_tool('forward', left, top+side)
draw_code_tool('left', left, top+2*side)
draw_code_tool('right', left, top+3*side)
end
function draw_code_tool(dir, left, top)
button(ui_state, t, {
x=left, y=top, w=side-1, h=side-1,
icon = function(p)
draw_code_step('', dir, p.x, p.y)
end,
onpress1 = function()
drag = {'code', dir}
end
})
end
function draw_code(left, top)
ui_state.code = {left=left, top=top}
color(0.5, 0.5, 0.5)
local x, y = left, top
for i=1,20 do
draw_code_step(i, code[i], x, y)
if i%5 > 0 then
x = x + side
else
y = y + side
x = left
end end
color(0,0,0)
g.print('instructions', left+2.5*side-App.width('instructions')/2, top+side*4)
end
function draw_code_step(i, dir, x, y)
if dir == nil then
color(0.5, 0.5, 0.5)
g.setLineWidth(1)
rect('line', x,y, side, side)
g.print(i, x+5, y+5)
elseif dir == 'forward' then
color(0.4, 0.8, 0.8)
rect('fill', x,y, side, side)
color(0,0,0)
g.print(i, x+5, y+5)
color(1, 0.1, 0)
g.setLineWidth(4)
line(x+side/2, y+side/4, x+side/2, y+side-side/8)
poly('fill', x+side/2, y+side/8, x+side/2-side/8, y+side/4, x+side/2+side/8, y+side/4)
elseif dir == 'left' then
color(0, 0.5, 0)
rect('fill', x,y, side, side)
color(0,0,0)
g.print(i, x+5, y+5)
color(1, 1, 0)
g.setLineWidth(4)
arc('line', 'open', x+side/4, y+side-side/8, side/2, 0, -pi/2)
local x, y = x+side/4-side/16, y+side/2-side/8
poly('fill', x, y, x+side/8, y-side/8, x+side/8, y+side/8)
elseif dir == 'right' then
color(0, 0, 0.4)
rect('fill', x,y, side, side)
color(0.6,0.6,0.6)
g.print(i, x+5, y+5)
color(1, 1, 0)
g.setLineWidth(4)
arc('line', 'open', x+side*3/4, y+side-side/8, side/2, pi, pi*3/2)
local x, y = x+side*3/4, y+side/2-side/8
poly('fill', x+side/16, y, x-side/16, y-side/8, x-side/16, y+side/8)
end end
-- rabbot
function draw_rabbot()
if drag == nil then
local x, y = rabbot_position(rabbot_state.x, rabbot_state.y, rabbot_state.dir)
button(ui_state, 'rabbot', {
x=x, y=y, w=32, h=32,
icon = function(p)
draw_sprite(rabbot, 2, p.x,p.y, rabbot_palette)
end,
onpress1 = function()
drag = {'rabbot'}
end,
})
elseif drag[1] == 'rabbot' then
draw_rabbot_drop_zones()
local mx,my = love.mouse.getPosition()
draw_sprite(rabbot, 2, mx,my, rabbot_palette)
end end
Finally the third screen, save it as `rabbot-run`:
run_screen('rabbot')
run_screen('rabbot-draw')
-- trigger run by releasing the rabbot sprite
function drop_rabbot()
local mx,my = love.mouse.getPosition()
-- up
for x=1,4 do
local px, py = rabbot_drop_zone(x, 0, 3)
if in_rabbot_drop_zone(px,py, mx,my) then
print('drop', x, 0, 3)
rabbot_state = {x=x, y=0, dir=3}
return
end
end
-- left
for y=1,4 do
local px, py = rabbot_drop_zone(0, y, 2)
if in_rabbot_drop_zone(px,py, mx,my) then
print('drop', 0, y, 2)
rabbot_state = {x=0, y=y, dir=2}
return
end
end
-- down
for x=1,4 do
local px, py = rabbot_drop_zone(x, 5, 1)
if in_rabbot_drop_zone(px,py, mx,my) then
print('drop', x, 5, 1)
rabbot_state = {x=x, y=5, dir=1}
return
end
end
-- right
for y=1,4 do
local px, py = rabbot_drop_zone(5, y, 4)
if in_rabbot_drop_zone(px,py, mx,my) then
print('drop', 5, y, 4)
rabbot_state = {x=5, y=y, dir=4}
return
end end
-- invalid
print('cannot drop rabbot there')
status('cannot drop rabbot there')
end
-- valid drop zones
function draw_rabbot_drop_zones()
color(0.5,0.5,0.5, 0.5)
-- up
for x=1,4 do
local x, y = rabbot_drop_zone(x, 0, 3)
rect('fill', x,y, side-1,side-1)
end
-- left
for y=1,4 do
local x, y = rabbot_drop_zone(0, y, 2)
rect('fill', x,y, side-1,side-1)
end
-- down
for x=1,4 do
local x, y = rabbot_drop_zone(x, 5, 1)
rect('fill', x,y, side-1,side-1)
end
-- right
for y=1,4 do
local x, y = rabbot_drop_zone(5, y, 4)
rect('fill', x,y, side-1,side-1)
end end
function step_rabbot()
if rabbot_state.status then rabbot_state.ip = nil end
if rabbot_state.ip == nil then return end
if rabbot_state.ip > 20 then return end
if Current_time < rabbot_state.next_update then return end
traverse_edge()
if rabbot_state.status then return end
traverse_tile_with_instruction(rabbot_state.ip)
rabbot_state.ip = rabbot_state.ip+1
rabbot_state.next_update = Current_time + animation_frame_duration
end
function traverse_edge()
local r = rabbot_state
-- compute next tile to move to
print('jump', r.x, r.y, r.dir)
if r.dir == 1 then r.y = r.y-1
elseif r.dir == 2 then r.x = r.x+1
elseif r.dir == 3 then r.y = r.y+1
elseif r.dir == 4 then r.x = r.x-1
else return status('bug: invalid dir')
end
r.dir = across(r.dir)
-- out of bounds after any jumping (i.e. ignoring start) = success
if r.x < 1 or r.x > #board or r.y < 1 or r.y > #board then
print('=>', r.x, r.y, r.dir)
status('across!')
end
end
function traverse_tile_with_instruction(i)
local r = rabbot_state
local tile = board[r.y][r.x]
print('tile', r.x, r.y, 'is', tile)
-- check that there's track in the starting direction
local o, t = tile%10, floor(tile/10) -- ones, tens
if r.dir ~= o and r.dir ~= t then return status('fell off the track') end
-- compare end direction of current tile and instruction
local a, e = o, nil -- actual, expected end dir
if r.dir == o then a = t end
if code[i] == nil then return status('need more instructions') end
if code[i] == 'forward' then
e = across(r.dir)
elseif code[i] == 'left' then
e = clockwise(r.dir)
elseif code[i] == 'right' then
e = anticlockwise(r.dir)
else
return status('bug: invalid instruction')
end
if e ~= a then return status('instruction is wrong', e, a) end
r.dir = e
table.insert(rabbot_state.path, {r.x,r.y}) -- success
end
function rabbot_position(x,y, d)
local x, y = rabbot_drop_zone(x,y, d)
if d == 1 then -- left
x = x+side/2-16
elseif d == 2 then -- right
x = x+side-32
y = y+side/2-16
elseif d == 3 then -- down
x = x+side/2-16
y = y+side-32
elseif d == 4 then -- left
y = y+side/2-16
end
return x, y
end
function rabbot_drop_zone(x,y, d)
local x = ui_state.board.left+side*(x-1)
local y = ui_state.board.top+side*(y-1)
return x, y
end
function in_rabbot_drop_zone(px,py, mx,my)
return mx >= px and mx < px+side and my >= py and my < py+side
end
function status(msg) rabbot_state.status = msg end
function across(dir) return (dir+2-1)%4+1 end
function clockwise(dir) return (dir+1-1)%4+1 end
function anticlockwise(dir) return (dir+3-1)%4+1 end
function draw_rabbot_path()
if rabbot_state.path == nil then return end
color(0,0,0)
for i,p in ipairs(rabbot_state.path) do
local x, y = unpack(p)
g.print(i, ui_state.board.left+(x-1)*side+5, ui_state.board.top+(y-1)*side+5)
end end
After saving all 3, hit run on the final screen (it'll pull in the others as needed). You'll need to first run the abbreviations on one of the example screens. Or if you've deleted that screen, here are the abbreviations I used in this post:
g = love.graphics line, rect, poly = g.line, g.rectangle, g.polygon arc = g.arc color = g.setColor min, max = math.min, math.max floor, ceil = math.floor, math.ceil pi = math.pi
Forgive the poor quality art for the bot. I'd appreciate improvements.
Also, if you've read this far, please send me any programs you've built using Lua Carousel. They can be big or tiny, working or broken. I'd appreciate hearing from you.
Files
Get Lua Carousel
Lua Carousel
Write programs on desktop and mobile
| Status | In development |
| Category | Tool |
| Author | Kartik Agaram |
| Tags | LÖVE |
More posts
- Programming on your device with your preferred language73 days ago
- Lua Carousel: program on the device you have, with docs at your fingertipsMay 12, 2025
- Pong Wars, MMO editionFeb 16, 2025
- New version after 41 days, and stop-motion animationFeb 15, 2025
- Drawing with a pen on a pendulumJan 11, 2025
- New version after 16 daysJan 04, 2025
- New version after 9 daysDec 19, 2024
- New version after 3 daysNov 17, 2024
- New version after 40 daysNov 14, 2024
- Turn your phone or tablet into a chess clockNov 01, 2024
Leave a comment
Log in with itch.io to leave a comment.