It’s… Snake3D! Exactly what you think!


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?

Project Night Cobra!