It’s snake! But in 3D! It’s Snake3D!
-- Voxel Snake 3D -- fixed decorative D-pad, screen-relative steering
-- Dependencies: Cameras (OrbitViewer)
viewer.mode=FULLSCREEN
N=33
PAD_SCALE=1.5
HEAD=color(150,255,140)
BODY=color(30,165,95)
FOOD=color(255,70,60)
CAGE=color(202, 117, 16)
GROW=3
TAP_THRESHOLD=14 -- px of movement before a touch stops counting as a tap
GAMEOVER_SPIN=35 -- degrees/sec the camera auto-orbits after a loss
TEXT_COLOR=color(90,230,220) -- cyan-ish, reads clearly against the warm sky
function setup()
imgUp=readImage(asset.builtin.UI.Green_Sliderup)
imgDown=readImage(asset.builtin.UI.Green_Sliderdown)
imgLeft=readImage(asset.builtin.UI.Green_Sliderleft)
imgRight=readImage(asset.builtin.UI.Green_Sliderright)
assert(OrbitViewer,"Please include Cameras as a dependency")
math.randomseed(os.time())
scene=craft.scene()
craft.scene.main=scene
scene.sky.material.sky=color(235,171,72)
scene.sky.material.horizon=color(227,158,113)
scene.sky.material.ground=color(200,60,73)
local c=(N+1)/2
cubeCenter=vec3(c,c,c)
local dist=N*2.8 -- scales the orbit distance to whatever N is
obv=scene.camera:add(OrbitViewer,cubeCenter,dist,8,120)
obv.rx=18
scene.voxels.blocks:addAssetPack("Blocks")
-- single chunk, sized to actually fit N+2 (default chunk size is only
-- 16 wide, which silently clips anything past x/z=16)
scene.voxels:resize(vec3(1,1,1),vec3(N+2,128,N+2))
scene.voxels.coordinates=vec3(0,0,0)
padAnchor=vec2(WIDTH/2,130)
heldTouch={}
pendingTap={}
titleTouchId=nil
titleTouchStart=nil
titleTouchMoved=false
best=0
parameter.integer("speed",1,12,6)
parameter.integer("padR",15,70,25)
parameter.boolean("autopilot",false)
parameter.action("Restart",function() reset() end)
buildCage()
gameState="title"
reset()
end
function key(x,y,z) return (x*(N+2)+y)*(N+2)+z end
function vkey(v) return key(v.x,v.y,v.z) end
function paint(v,c)
scene.voxels:fill(BLOCK_NAME,"Solid",COLOR,c)
scene.voxels:block(v.x,v.y,v.z)
end
function erase(v)
scene.voxels:fill(BLOCK_NAME,"empty")
scene.voxels:block(v.x,v.y,v.z)
end
function buildCage()
scene.voxels:fill(BLOCK_NAME,"Solid",COLOR,CAGE)
local a,b=0,N+1
local corners={{a,a},{a,b},{b,a},{b,b}}
for i=1,4 do
local p,q=corners[i][1],corners[i][2]
scene.voxels:line(a,p,q,b,p,q)
scene.voxels:line(p,a,q,p,b,q)
scene.voxels:line(p,q,a,p,q,b)
end
end
function reset()
scene.voxels:fill(BLOCK_NAME,"empty")
scene.voxels:box(1,1,1,N,N,N)
local c=math.floor(N/2)
snake={}
occ={}
for i=0,5 do
local v=vec3(c+3-i,c,c)
snake[#snake+1]=v
occ[vkey(v)]=true
paint(v,i==0 and HEAD or BODY)
end
dir=vec3(1,0,0)
grow=0
score=0
acc=0
heldTouch={}
pendingTap={}
spawnFood()
end
function spawnFood()
local t={}
for x=1,N do for y=1,N do for z=1,N do
if not occ[key(x,y,z)] then t[#t+1]=vec3(x,y,z) end
end end end
if #t==0 then food=nil return end
food=t[math.random(#t)]
paint(food,FOOD)
end
function free(v,tailSet)
if v.x<1 or v.y<1 or v.z<1 or v.x>N or v.y>N or v.z>N then return false end
local k=vkey(v)
if tailSet and tailSet[k] then return true end
return not occ[k]
end
PERM2={{1,2},{2,1}}
PERM3={{1,2,3},{1,3,2},{2,1,3},{2,3,1},{3,1,2},{3,2,1}}
function pathFor(head,d,tailSet)
local comp={d.x,d.y,d.z}
local ax={}
for i=1,3 do if comp[i]~=0 then ax[#ax+1]=i end end
local k=#ax
if k==0 then return nil end
local perms
if k==1 then perms={{1}} elseif k==2 then perms=PERM2 else perms=PERM3 end
for p=1,#perms do
local order=perms[p]
local cur=vec3(head.x,head.y,head.z)
local path={}
local ok=true
for i=1,k do
local a=ax[order[i]]
if a==1 then cur=vec3(cur.x+comp[1],cur.y,cur.z)
elseif a==2 then cur=vec3(cur.x,cur.y+comp[2],cur.z)
else cur=vec3(cur.x,cur.y,cur.z+comp[3]) end
if not free(cur,tailSet) then ok=false break end
path[#path+1]=vec3(cur.x,cur.y,cur.z)
end
if ok then return path end
end
return nil
end
FACE={vec3(1,0,0),vec3(-1,0,0),vec3(0,1,0),vec3(0,-1,0),vec3(0,0,1),vec3(0,0,-1)}
function space(cells,limit)
local seen={}
local stack={}
for i=1,#cells do
local k=vkey(cells[i])
if not seen[k] then seen[k]=true stack[#stack+1]=cells[i] end
end
local n=0
while #stack>0 do
local cur=table.remove(stack)
n=n+1
if n>=limit then return n end
for i=1,6 do
local q=cur+FACE[i]
local k=vkey(q)
if not seen[k] and free(q) then seen[k]=true stack[#stack+1]=q end
end
end
return n
end
function heuristic(c)
local dx,dy,dz=math.abs(c.x-food.x),math.abs(c.y-food.y),math.abs(c.z-food.z)
return math.max(dx,dy,dz)+(dx+dy+dz)*0.01
end
DIRS26={}
for x=-1,1 do
for y=-1,1 do
for z=-1,1 do
if x~=0 or y~=0 or z~=0 then DIRS26[#DIRS26+1]=vec3(x,y,z) end
end
end
end
function isReverse(d) return d.x==-dir.x and d.y==-dir.y and d.z==-dir.z end
function autoDir(tailSet)
local head=snake[1]
local cand={}
for i=1,#DIRS26 do
local d=DIRS26[i]
if not isReverse(d) and d:dot(dir)>=0 then
local path=pathFor(head,d,tailSet)
if path then cand[#cand+1]={d=d,path=path,h=heuristic(path[#path])} end
end
end
if #cand==0 then return nil end
table.sort(cand,function(a,b) return a.h<b.h end)
local limit=#snake+2
local pick,pickSp=nil,-1
for i=1,#cand do
local sp=space(cand[i].path,limit)
if sp>=limit then return cand[i] end
if sp>pickSp then pickSp=sp pick=cand[i] end
end
return pick
end
-- world-space camera right/up, snapped to the nearest lattice axis.
-- reads the camera's own right/up directly rather than reconstructing
-- them from position -- that reconstruction required guessing the cross
-- product's handedness, which is the likely cause of "up" not reliably
-- meaning screen-up.
function camAxesSnapped()
return snapAxis(scene.camera.right),snapAxis(scene.camera.up)
end
function snapAxis(v)
local ax,ay,az=math.abs(v.x),math.abs(v.y),math.abs(v.z)
if ax>=ay and ax>=az then return vec3(v.x>=0 and 1 or -1,0,0) end
if ay>=ax and ay>=az then return vec3(0,v.y>=0 and 1 or -1,0) end
return vec3(0,0,v.z>=0 and 1 or -1)
end
function snap(v)
if v>0.33 then return 1 elseif v<-0.33 then return -1 else return 0 end
end
-- true if a button is currently physically held, OR was tapped since the
-- last tick consumed it -- the latter is what keeps a quick tap from
-- landing in the gap between two ticks and getting silently dropped
function isPressed(name)
if pendingTap[name] then return true end
for id,n in pairs(heldTouch) do
if n==name then return true end
end
return false
end
function padDir(tailSet)
local head=snake[1]
local cr,cu=camAxesSnapped()
local sum=vec3(0,0,0)
local any=false
if isPressed("up") then sum=sum+cu any=true end
if isPressed("down") then sum=sum-cu any=true end
if isPressed("right") then sum=sum-cr any=true end
if isPressed("left") then sum=sum+cr any=true end
if any then
local nd=vec3(snap(sum.x),snap(sum.y),snap(sum.z))
local isZero=(nd.x==0 and nd.y==0 and nd.z==0)
local isReversal=(nd.x==-dir.x and nd.y==-dir.y and nd.z==-dir.z)
if not isZero and not isReversal then
local path=pathFor(head,nd,tailSet)
if path then return {d=nd,path=path} end
end
end
-- nothing pressed, or the press was a no-op/illegal/blocked -- just
-- keep going, the "best guess" for any sticky situation
local path=pathFor(head,dir,tailSet)
if path then return {d=dir,path=path} end
return nil
end
function step()
if not food then reset() return end
local tailSet=nil
if grow<=0 and #snake>4 then tailSet={[vkey(snake[#snake])]=true} end
local pick
if gameState=="title" or autopilot then pick=autoDir(tailSet) else pick=padDir(tailSet) end
pendingTap={} -- consumed for this tick, whether or not it changed anything
if not pick then
if gameState=="title" then
reset() -- demo boxed itself in -- just loop, no need to show anything
return
end
gameState="gameover"
return
end
dir=pick.d
local path=pick.path
local rm=0
for i=1,#path do
if grow>0 then grow=grow-1 else rm=rm+1 end
end
paint(snake[1],BODY)
for i=1,rm do
if #snake>1 then
local t=table.remove(snake)
occ[vkey(t)]=nil
erase(t)
end
end
local ate=false
for i=1,#path do
local cc=path[i]
if vkey(cc)==vkey(food) then ate=true end
table.insert(snake,1,cc)
occ[vkey(cc)]=true
paint(cc,BODY)
end
paint(snake[1],HEAD)
if ate then
score=score+1
if score>best then best=score end
grow=grow+GROW
spawnFood()
end
end
function computePadPos()
local r=padR*PAD_SCALE
local function at(deg) return padAnchor+vec2(math.cos(math.rad(deg)),math.sin(math.rad(deg)))*r end
return { up=at(90), right=at(0), down=at(270), left=at(180) }
end
function hitButton(x,y)
local pos=computePadPos()
local half={
up ={imgUp.width/2*PAD_SCALE+8, imgUp.height/2*PAD_SCALE+8},
down ={imgDown.width/2*PAD_SCALE+8, imgDown.height/2*PAD_SCALE+8},
left ={imgLeft.width/2*PAD_SCALE+8, imgLeft.height/2*PAD_SCALE+8},
right={imgRight.width/2*PAD_SCALE+8,imgRight.height/2*PAD_SCALE+8},
}
for name,p in pairs(pos) do
local hw,hh=half[name][1],half[name][2]
if math.abs(x-p.x)<hw and math.abs(y-p.y)<hh then return name end
end
return nil
end
-- title: any touch orbits the camera as normal; only a touch that never
-- moves past TAP_THRESHOLD counts as a tap and starts the game. gameover:
-- camera control is off entirely, any tap goes back to the title screen.
-- playing: a touch that started on a button is consumed for its whole
-- lifetime so it never also drags the camera.
function touched(t)
if gameState=="gameover" then
if t.state==BEGAN then
gameState="title"
reset()
end
return
end
if gameState=="title" then
if t.state==BEGAN then
if titleTouchId==nil then
titleTouchId=t.id
titleTouchStart=vec2(t.x,t.y)
titleTouchMoved=false
else
titleTouchMoved=true -- a second finger down -- not a tap
end
elseif t.id==titleTouchId then
if vec2(t.x,t.y):dist(titleTouchStart)>TAP_THRESHOLD then titleTouchMoved=true end
if t.state==ENDED or t.state==CANCELLED then
if not titleTouchMoved and t.state==ENDED then
gameState="playing"
reset()
end
titleTouchId=nil
end
end
obv:touched(t)
return
end
-- playing
if t.state==BEGAN then
local btn=hitButton(t.x,t.y)
if btn then
heldTouch[t.id]=btn
pendingTap[btn]=true
return
end
elseif heldTouch[t.id] then
if t.state==ENDED or t.state==CANCELLED then heldTouch[t.id]=nil end
return
end
obv:touched(t)
end
function drawPad()
local pos=computePadPos()
pushStyle()
spriteMode(CENTER)
local function drawBtn(img,name)
local p=pos[name]
pushMatrix()
translate(p.x,p.y)
rotate(180)
if isPressed(name) then tint(90,90,90) else noTint() end
sprite(img,0,0,img.width*PAD_SCALE,img.height*PAD_SCALE)
popMatrix()
end
drawBtn(imgUp,"up")
drawBtn(imgDown,"down")
drawBtn(imgLeft,"left")
drawBtn(imgRight,"right")
noTint()
popStyle()
end
function drawTitleOverlay()
pushStyle()
fill(TEXT_COLOR)
textMode(CENTER)
fontSize(34)
text("VOXEL SNAKE",WIDTH/2,HEIGHT-130)
fontSize(18)
textWrapWidth(300)
textAlign(CENTER)
text("The d-pad works off of absolute screen direction. So the direction you tap is the direction the snake goes.\n\nDrag to look around.",WIDTH/2,HEIGHT-220)
fontSize(24)
text("Tap to start",WIDTH/2,HEIGHT-330)
popStyle()
end
function draw()
if gameState=="title" or gameState=="playing" then
acc=acc+math.min(DeltaTime,0.1)
local tick=1/speed
while acc>=tick do
acc=acc-tick
step()
end
else -- gameover: camera control is off, so spin it ourselves instead
obv.ry=obv.ry+GAMEOVER_SPIN*DeltaTime
end
scene:draw()
if gameState=="playing" then drawPad() end
pushStyle()
textMode(CORNER)
fontSize(20)
fill(TEXT_COLOR)
text("length "..#snake.." eaten "..score.." best "..best,20,HEIGHT-80)
popStyle()
if gameState=="title" then
drawTitleOverlay()
elseif gameState=="gameover" then
pushStyle()
fill(TEXT_COLOR)
fontSize(36)
textMode(CENTER)
text("tap to restart",WIDTH/2,HEIGHT/2)
popStyle()
end
end
Should be on WebRepo soon. Vibe Coded.
So… what should I add to it next? Textures? Colors? Obstacles?
What I think would be the coolest ever would be to have two automatic snakes writhing around and you get to be a guy who has to jump between them.
Suggestions?
Anybody want to add something themselves?
