apps: add 2048 to the Store (pisti87, #265)

Community submission, reviewed before publishing rather than taken on trust.

The whole API surface it touches is ui.label / ui.canvas / ui.colors, sys.random,
timer.every(100) and two store keys for the high score. No network, no mesh
transmit, no filesystem, no dynamic code loading (load/loadstring/require/dofile),
no _G, metatable, debug or coroutine tricks, and no long-string or numeric-escape
obfuscation. Persistence is one bounded value. Nothing in it can reach past its
own window, so it needs no permission grant.

It also uses the real lifecycle contract — an app table with on_open/on_tick/
on_input/on_close, returned from the chunk — which is worth noting because the
published SDK page told people to do it a different way until today. He got it
right by reading the shipped apps.

Parse-checked against the firmware's own vendored Lua 5.4.7 rather than a system
interpreter, so the syntax is verified by the same parser the device runs. Not
run on hardware here; pisti87 reports it working on a T-Deck Plus.

deploy-apps.sh now also verifies that every apps.json entry has its
<id>/<ver>/<id>.lua and .json on disk. An entry pointing at a missing file lists
in the Store and then fails to install with nothing on screen to explain why —
the same failure the language check already prevents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kaj Schittecat
2026-08-13 20:56:44 +02:00
co-authored by Claude Opus 5
parent de45b1b158
commit 8cc4a2b981
4 changed files with 885 additions and 8 deletions
+1
View File
@@ -0,0 +1 @@
{"id":"2048","name":"2048","ver":"1.2","desc":"The classic sliding-tile puzzle. Swipe to move, tap to restart. Keeps your best score. Contributed by pisti87."}
+837
View File
@@ -0,0 +1,837 @@
-- 2048 — wada.* reference app
-- Swipe (or trackball mapped swipe) to move tiles.
-- Tap after win or game over to restart.
local ui, sys, store, timer = wada.ui, wada.sys, wada.store, wada.timer
local C = ui.colors
local app = {}
local cv, score_lbl
local CELL = 38
local grid = {}
local history = {
grids = {},
maxsize = 5
}
local px_w, px_h
local score = 0
local hiscore = 0
local over = false
local won = false
--------------------------------------------------
-- History
--------------------------------------------------
function history:save()
local old = {
grid = {},
score = score,
over = over,
won = won
}
for x = 1, 4 do
old.grid[x] = {}
for y = 1, 4 do
old.grid[x][y] = grid[x][y]
end
end
table.insert(self.grids, old)
if #self.grids > self.maxsize then
table.remove(self.grids, 1)
end
end
function history:revert()
if #self.grids > 0 then
local old = table.remove(self.grids)
grid = old.grid
score = old.score
over = old.over
won = old.won
end
end
--------------------------------------------------
-- Game logic
--------------------------------------------------
local function clear_grid()
grid = {}
for x = 1, 4 do
grid[x] = {}
for y = 1, 4 do
grid[x][y] = 0
end
end
end
--------------------------------------------------
-- Add random tile
--------------------------------------------------
local function add_random_tile()
local free = {}
for x = 1, 4 do
for y = 1, 4 do
if grid[x][y] == 0 then
table.insert(
free,
{
x = x,
y = y
}
)
end
end
end
-- Board is full.
if #free == 0 then
return false
end
local p
-- If there is only one empty cell,
-- use it directly.
if #free == 1 then
p = free[1]
else
local index = sys.random(1, #free)
if index == nil then
return false
end
p = free[index]
if p == nil then
return false
end
end
-- 10% chance for 4,
-- 90% chance for 2.
if sys.random(0, 9) == 0 then
grid[p.x][p.y] = 4
else
grid[p.x][p.y] = 2
end
return true
end
--------------------------------------------------
-- Reset
--------------------------------------------------
local function reset()
clear_grid()
score = 0
over = false
won = false
history.grids = {}
add_random_tile()
add_random_tile()
end
--------------------------------------------------
-- Compress line
--------------------------------------------------
local function compress(line)
local r = {}
for i = 1, 4 do
if line[i] ~= 0 then
table.insert(r, line[i])
end
end
while #r < 4 do
table.insert(r, 0)
end
return r
end
--------------------------------------------------
-- Merge line
--------------------------------------------------
local function merge(line)
local r = {}
local i = 1
while i <= 4 do
if i < 4
and line[i] ~= 0
and line[i] == line[i + 1]
then
local v = line[i] * 2
table.insert(r, v)
score = score + v
-- Permanent high score.
if score > hiscore then
hiscore = score
store.set(
"2048_best",
hiscore
)
end
-- Reaching 2048 means victory.
if v >= 2048 then
won = true
end
i = i + 2
else
table.insert(r, line[i])
i = i + 1
end
end
while #r < 4 do
table.insert(r, 0)
end
return r
end
--------------------------------------------------
-- Move left
--------------------------------------------------
local function move_left()
local changed = false
for y = 1, 4 do
local old = {}
local line = {}
for x = 1, 4 do
old[x] = grid[x][y]
line[x] = grid[x][y]
end
line = compress(line)
line = merge(line)
for x = 1, 4 do
grid[x][y] = line[x]
if old[x] ~= line[x] then
changed = true
end
end
end
return changed
end
--------------------------------------------------
-- Rotate
--------------------------------------------------
local function rotate()
local n = {}
for x = 1, 4 do
n[x] = {}
end
for x = 1, 4 do
for y = 1, 4 do
n[y][5 - x] = grid[x][y]
end
end
grid = n
end
--------------------------------------------------
-- Game over
--------------------------------------------------
local function game_over()
-- Any empty cell means the game can continue.
for x = 1, 4 do
for y = 1, 4 do
if grid[x][y] == 0 then
return false
end
end
end
-- Check horizontal and vertical merges.
for x = 1, 4 do
for y = 1, 4 do
if x < 4
and grid[x][y] == grid[x + 1][y]
then
return false
end
if y < 4
and grid[x][y] == grid[x][y + 1]
then
return false
end
end
end
-- Board is full and no merge is possible.
return true
end
--------------------------------------------------
-- Move
--------------------------------------------------
local function move(dir)
-- Do nothing after victory or defeat.
if over or won then
return
end
history:save()
local changed = false
if dir == "left" then
changed = move_left()
elseif dir == "right" then
rotate()
rotate()
changed = move_left()
rotate()
rotate()
elseif dir == "up" then
rotate()
changed = move_left()
rotate()
rotate()
rotate()
elseif dir == "down" then
rotate()
rotate()
rotate()
changed = move_left()
rotate()
end
--------------------------------------------------
-- Nothing moved.
--------------------------------------------------
if not changed then
history:revert()
return
end
--------------------------------------------------
-- Successful move.
-- Exactly one new tile must be created.
--------------------------------------------------
local added = add_random_tile()
--------------------------------------------------
-- This should only happen if the board was already
-- completely full. Normally a successful move on a
-- full board creates space, so added should be true.
--------------------------------------------------
if not added then
over = true
return
end
--------------------------------------------------
-- Victory.
--
-- merge() sets won when a 2048 tile is created.
--------------------------------------------------
if won then
return
end
--------------------------------------------------
-- IMPORTANT:
--
-- Game-over is checked AFTER the new random tile
-- has been inserted.
--------------------------------------------------
if game_over() then
over = true
end
end
--------------------------------------------------
-- Tile colors
--------------------------------------------------
local function tile_color(v)
if v == 0 then
return 0xCDC1B4
elseif v == 2 then
return 0xEEE4DA
elseif v == 4 then
return 0xEDE0C8
elseif v == 8 then
return 0xF2B179
elseif v == 16 then
return 0xF59563
elseif v == 32 then
return 0xF67C5F
elseif v == 64 then
return 0xF65E3B
elseif v == 128 then
return 0xEDCF72
elseif v == 256 then
return 0xEDCC61
elseif v == 512 then
return 0xEDC850
elseif v == 1024 then
return 0xEDC53F
elseif v == 2048 then
return 0xEDC22E
end
-- Values above 2048.
return 0x3C3A32
end
--------------------------------------------------
-- Text colors
--------------------------------------------------
local function text_color(v)
if v <= 4 then
return 0x776E65
else
return 0xFFFFFF
end
end
--------------------------------------------------
-- Text size
--------------------------------------------------
local function text_size(v)
if v < 100 then
return 16
elseif v < 1000 then
return 14
elseif v < 10000 then
return 11
else
return 9
end
end
--------------------------------------------------
-- Text positioning
--------------------------------------------------
local function text_x(px, v, size)
local width
if v < 100 then
width = size * 1.1
elseif v < 1000 then
width = size * 1.7
elseif v < 10000 then
width = size * 2.3
else
width = size * 2.9
end
return px + math.floor(
(CELL - width) / 2
)
end
local function text_y(py, size)
return py + math.floor(
(CELL - size) / 2
)
end
--------------------------------------------------
-- Score line
--------------------------------------------------
local function scoreline()
local status = ""
if won then
status = " - tap retry"
elseif over then
status = " - tap retry"
end
score_lbl:set(
string.format(
"Score %d Best %d%s",
score,
hiscore,
status
)
)
end
--------------------------------------------------
-- Draw
--------------------------------------------------
local function draw()
cv:fill(0x101418)
for x = 1, 4 do
for y = 1, 4 do
local px = (x - 1) * CELL
local py = (y - 1) * CELL
local v = grid[x][y]
cv:rect(
px + 2,
py + 2,
CELL - 4,
CELL - 4,
tile_color(v),
true,
4
)
if v > 0 then
local size = text_size(v)
cv:text(
text_x(px, v, size),
text_y(py, size),
tostring(v),
text_color(v),
size
)
end
end
end
--------------------------------------------------
-- End messages
--------------------------------------------------
if won then
cv:text(
30,
px_h / 2,
"YOU WIN!",
C.good,
18
)
elseif over then
cv:text(
30,
px_h / 2,
"GAME OVER",
C.bad,
18
)
end
end
--------------------------------------------------
-- WADAMESH API
--------------------------------------------------
function app.on_open(w, h)
hiscore = store.get(
"2048_best",
0
)
px_w = CELL * 4
px_h = CELL * 4
score_lbl = ui.label(
"",
4,
4,
14,
C.text
)
cv = ui.canvas(
px_w,
px_h
)
cv:pos(
math.floor(
(w - px_w) / 2
),
28
)
reset()
scoreline()
draw()
timer.every(100)
end
function app.on_input(ev)
if ev.type == "swipe" then
move(ev.dir)
scoreline()
draw()
elseif ev.type == "down"
and (over or won)
then
reset()
scoreline()
draw()
end
end
function app.on_tick(dt)
end
function app.on_close()
end
return app
+34 -6
View File
@@ -1,6 +1,34 @@
{"apps":[
{"id":"monitor","name":"RF Monitor","ver":"1.3","desc":"Scrollable: RSSI/noise chart with dBm axis, link margin, RX rate, radio params, heard feed."},
{"id":"airtime","name":"Airtime","ver":"1.3","desc":"Utilization bars with a % axis, rx/tx split, duty ceiling, budget. Reset button top-right."},
{"id":"snake","name":"Snake","ver":"1.0","desc":"The classic, in Lua. Swipe to steer."},
{"id":"sdktest","name":"SDK Test","ver":"1.1","desc":"Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, file access and its limits, keyboard input. Has a button that TRANSMITS a test message to Public (asks permission first)."}
]}
{
"apps": [
{
"id": "monitor",
"name": "RF Monitor",
"ver": "1.3",
"desc": "Scrollable: RSSI/noise chart with dBm axis, link margin, RX rate, radio params, heard feed."
},
{
"id": "airtime",
"name": "Airtime",
"ver": "1.3",
"desc": "Utilization bars with a % axis, rx/tx split, duty ceiling, budget. Reset button top-right."
},
{
"id": "snake",
"name": "Snake",
"ver": "1.0",
"desc": "The classic, in Lua. Swipe to steer."
},
{
"id": "sdktest",
"name": "SDK Test",
"ver": "1.1",
"desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, file access and its limits, keyboard input. Has a button that TRANSMITS a test message to Public (asks permission first)."
},
{
"id": "2048",
"name": "2048",
"ver": "1.2",
"desc": "The classic sliding-tile puzzle. Swipe to move, tap to restart. Keeps your best score. Contributed by pisti87."
}
]
}
+13 -2
View File
@@ -44,8 +44,19 @@ missing = [f"{l['code']} v{l['ver']}" for l in langs
if not os.path.isfile(os.path.join(apps_dir, "lang", str(l["ver"]), l["code"] + ".lang"))]
if missing:
sys.exit("ABORT: langs.json points at files that do not exist: " + ", ".join(missing))
json.load(open(os.path.join(apps_dir, "apps.json"))) # parse check
print(f"catalogs ok: {len(langs)} languages, every referenced .lang present")
# Same check for apps: an entry whose <id>/<ver>/<id>.lua is absent shows up in the
# Store and then fails to install, with nothing on screen to say why.
apps = json.load(open(os.path.join(apps_dir, "apps.json")))["apps"]
bad = []
for a in apps:
base = os.path.join(apps_dir, a["id"], a["ver"])
for f in (a["id"] + ".lua", a["id"] + ".json"):
if not os.path.isfile(os.path.join(base, f)):
bad.append(f'{a["id"]} v{a["ver"]}: {f}')
if bad:
sys.exit("ABORT: apps.json points at files that do not exist: " + ", ".join(bad))
print(f"catalogs ok: {len(langs)} languages and {len(apps)} apps, every referenced file present")
PY
# --delete mirrors the repo: the store is generated content, the repo is the source