Struggling with Pseudo3D racing game engine

Hey @PizzaFuel @daprice I optimized my code to load graphic files only once. I also changed sprites to images. As I use only "playdate.graphics.image:drawScaled" I prepared already flipped sprites of tres and road signs for the opposite side of the road. Here is my current code:

import "CoreLibs/sprites"
import "CoreLibs/graphics"

local pd <const> = playdate
local gfx <const> = pd.graphics

--[[The road is made out of "corners" (for our purposes we will call straight bits "corners" as well). 
	Corners need to curve in the direction the road turns, so we will simulate this by building them out of smaller straight “segments”.
	We can define the track as an array of these corners. Each corner has a segment count "ct" and a value indicating how much the direction turns between each segment “tu”.
	So tu=0 creates a straight piece, tu=1 will turn to the right, -1 left etc. For simplicity we'll ignore hills and valleys for now.--]]


gfx.sprite.setAlwaysRedraw(true) -- ensures the entire background gets drawn in one go every frame, important if the background drawing callback does anything complex
gfx.setBackgroundColor(gfx.kColorWhite)
gfx.sprite.setBackgroundDrawingCallback(
	function( x, y, width, height )
        drawScreen()
	end
)

local leftSakuraTreeImage = gfx.image.new('images/left_sakura_tree')
local rightSakuraTreeImage = gfx.image.new('images/right_sakura_tree')
local leftTurnSignImage = gfx.image.new('images/left_turn_sign')
local rightTurnSignImage = gfx.image.new('images/right_turn_sign')
local playerCarImage = gfx.image.new('images/car')


local bg_left_sakura_tree = {
    img = "left_sakura_tree",
    pos={4.5,0},     -- position relative to side of road
    siz={3,4.5},     -- size
    spc=5,            -- spacing
    bgscl=0.06,        -- param to scale sprite
    flpr=true
}

local bg_right_sakura_tree = {
    img = "right_sakura_tree",
    pos={2,0},     -- position relative to side of road
    siz={3,4.5},     -- size
    spc=5,            -- spacing
    bgscl=0.06,        -- param to scale sprite
    flpr=true
}

local bg_left_turn_sign = {
    img = "left_turn_sign",
    pos={2,0},-- position relative to side of road
    siz={1.5,1.5},-- size
    spc=2,-- spacing
    bgscl=0.03,-- param to scale sprite
    flpr=true        -- flip when on right hand side

}

local bg_right_turn_sign = {
    img = "right_turn_sign",
    pos={2,0},-- position relative to side of road
    siz={1.5,1.5},-- size
    spc=2,-- spacing
    bgscl=0.03,-- param to scale sprite
    flpr=true        -- flip when on right hand side

}

local npcSprite = {
    img = "car",
    siz={1,1},
    bgscl=0.02,
    roadPosition=0,
    maxRoadSegments=0, -- moment when road position of the NPC should be reset to 1. A value is added in calculateRoad() function
    speed=0,
}


local road={
    {ct=40,tu=0,bgl=bg_left_sakura_tree,bgr=bg_right_sakura_tree},
    {ct=20,tu=.25,bgl=bg_left_turn_sign,bgr=bg_right_sakura_tree},
    {ct=40,tu=0,bgl=bg_left_sakura_tree,bgr=bg_right_sakura_tree},
    {ct=20,tu=-.25,bgl=bg_left_sakura_tree,bgr=bg_right_turn_sign},
    --{ct=20,tu=0,bgl=bg_tree,bgr=bg_tree},
}   


local drawnSprites = {}

local playerCarSpeed = 0

function npcDrive()
    if (npcSprite.roadPosition > npcSprite.maxRoadSegments) then npcSprite.roadPosition = 1 
    elseif npcSprite.speed > 1 then npcSprite.speed = 1 npcSprite.roadPosition += 1
    else npcSprite.speed += 0.2
    end
end

function calculateRoad()

    -- calculate the # of segments
    -- in the road before each corner.
    -- this is useful for spacing things
    -- out evenly along the road

    local sumct=0
    local i = 1
    while i <= #road do
    local corner = road[i]
    corner.sumct = sumct
    sumct = sumct + corner.ct
    i = i + 1
    end
    npcSprite.maxRoadSegments=sumct
end

function getsumct(cnr,seg)
	return road[cnr].sumct+seg-1
end

-- camera
   local camcnr,camseg=1,1
   local camx,camy,camz=0,0,0

-- function to turning 2D into psuedo 3D
function project(x,y,z)
	local scale=200/z
	return x*scale+200,y*scale+120,scale
end

-- advance along road, goes trhough segments of each corners and jump to another corner after last segment

function advance(cnr,seg)
	seg += 1
	if (seg > road[cnr].ct) then
		seg = 1
		cnr += 1
		if (cnr > #road) then
			cnr = 1
		end
	end
	return cnr, seg
end

function skew(x,y,z,xd,yd)
	return x+z*xd,y+z*yd,z
end

function drawScreen()

    -- cleaning screen from sprites added in previous animation frame
    --gfx.sprite.removeSprites(drawnSprites)

	-- direction
	local camang=camz*road[camcnr].tu
	local xd,yd,zd=-camang,0,1
	
	-- skew camera to account for direction 
	local cx,cy,cz=skew(camx,camy,camz,xd,yd)
	
	-- cursor, relative to skewed camera
	local x,y,z=-cx,-cy+2,-cz+2
	
	-- road position
	local cnr,seg=camcnr,camseg
	
	 -- previous projected position
	 local ppx,ppy,pscale=project(x,y,z)


     -- array of sprites to draw
    local spritesToDraw={} 
	
	-- draw forward
    for i=1, 30 do
		-- project (turns x, y, z into 3D using a scale)
		local px,py,scale=project(x,y,z)
		
        -- draw road
        local sumct=getsumct(cnr,seg)
        drawroad(px,py,scale,ppx,ppy,pscale,sumct)

        -- add background sprites
        addbgsprite(spritesToDraw,sumct,road[cnr].bgl,-1,px,py,scale)
        addbgsprite(spritesToDraw,sumct,road[cnr].bgr,1,px,py,scale)
        addNPCsprite(spritesToDraw,sumct,npcSprite,0,px,py,scale)


        --[[ add background sprites to draw
        if (sumct%6)==0 then
        -- left tree
        local tx,ty=px-4.5*scale,py
        local tw,th=1.5*scale,3*scale
        local tscale=0.06*scale
        table.insert(spritesToDraw,{x=tx,y=ty,w=tw,h=th,s=tscale})
        
        -- right tree
        tx=px+6.5*scale
        table.insert(spritesToDraw,{x=tx,y=ty,w=tw,h=th,s=tscale})
       end]]--

        
		-- move forward
		x+=xd
		y+=yd
		z+=zd
		
		-- turn, cnr means corners which are elements from road table
		xd+=road[cnr].tu
		
		-- advance along road, goes trhough segments of each corners and jump to another corner after last segment
		cnr, seg = advance(cnr,seg)

        -- track previous projected position
		ppx,ppy,pscale = px, py, scale

	end 

    -- draw background sprites in reverse order
    for i=#spritesToDraw,1,-1 do
    drawbgsprite(spritesToDraw[i])
    end
end



function addbgsprite(spritesToDraw,sumct,bg,side,px,py,scale)
    if(not bg) then return end
    if((sumct%bg.spc)~=0) then return end -- checks if sprite should be drawn or not
   
    -- find position
    px+=3*scale*side
    if bg.pos then
       px+=bg.pos[1]*scale*side
       py+=bg.pos[2]*scale
       --py+=-10
    end
   
    -- pico8 calculate size for proper placement
    local w,h=bg.siz[1]*scale,bg.siz[2]*scale
   
    -- flip horizontally?
    local flp=side>0 and bg.flpr
   
    -- set proper scaling
    local tscale=bg.bgscl*scale

    -- add to sprite array
        table.insert(spritesToDraw,{
            x=px,y=py,w=w,h=h,
            img=bg.img,
            s=tscale,
            flp=flp
        })
end

function addNPCsprite(spritesToDraw,sumct,bg,side,px,py,scale)
    if(not npcSprite) then return end
    --if ((bg.roadPosition-sumct)<=0) or ((bg.roadPosition-sumct)>1) then return end
    if not (((bg.roadPosition-sumct)==0)) then return end
    -- find position
    --px+=3*scale*side
    px-=1.5*scale
    if bg.pos then
       px+=bg.pos[1]*scale*side
       py+=bg.pos[2]*scale
    end
   
    -- pico8 calculate size for proper placement
    local w,h=bg.siz[1]*scale,bg.siz[2]*scale
   
    -- flip horizontally?
    local flp=side>0 and bg.flpr
   
    -- set proper scaling
    local tscale=bg.bgscl*scale

    -- add to sprite array
        table.insert(spritesToDraw,{
            x=px,y=py,w=w,h=h,
            img=bg.img,
            s=tscale,
            flp=flp
        })
end


function drawbgsprite(s)
    
-- checks whhich image should be put into drawing function
        if s.img == "left_sakura_tree" then leftSakuraTreeImage:drawScaled((s.x-s.w/2), (s.y-s.h), s.s) end
        if s.img == "right_sakura_tree" then rightSakuraTreeImage:drawScaled((s.x-s.w/2), (s.y-s.h), s.s) end
        if s.img == "left_turn_sign" then leftTurnSignImage:drawScaled((s.x-s.w/2), (s.y-s.h), s.s) end 
        if s.img == "right_turn_sign" then rightTurnSignImage:drawScaled((s.x-s.w/2), (s.y-s.h), s.s) end 
        if s.img == "car" then playerCarImage:drawScaled((s.x-s.w/2), (s.y-s.h), s.s) end 
    end  

    --local image = gfx.image.new(s.img)
    --local spriteToBeDrawn = gfx.sprite.new(image)
    --if s.flp then spriteToBeDrawn:setImageFlip(gfx.kImageFlippedX) end 
    --spriteToBeDrawn:setScale(s.s)
    --spriteToBeDrawn:moveTo(s.x-s.w/2,s.y-s.h)
    --spriteToBeDrawn:add()
    --table.insert(drawnSprites, spriteToBeDrawn)
    --end


function drawroad(x1,y1,scale1,x2,y2,scale2,sumct)

    if(math.floor(y2)<math.ceil(y1))then return end
   
    -- draw ground
    gfx.setColor(gfx.kColorBlack)
    gfx.setPattern({ 0x0f, 0xf0, 0x0f, 0xf0, 0x0f, 0xf0, 0x0f, 0xf0 }) 
    if((sumct%6)>=3) then 
        gfx.setColor(gfx.kColorWhite) 
        gfx.setPattern({ 0xee, 0xbb, 0xee, 0xbb, 0xee, 0xbb, 0xee, 0xbb })  
    end
    gfx.fillRect(0,math.ceil(y1),400, (y1-y2))
    
    -- main road
       local w1,w2=3*scale1,3*scale2            -- start and end widths
       gfx.setColor(gfx.kColorBlack)
       gfx.setPattern({ 0xaa, 0x55, 0xaa, 0x55, 0xaa, 0x55, 0xaa, 0x55 })
       --drawTrapezium(x1,y1,w1,x2,y2,w2)
       gfx.fillPolygon(x1-w1, y1, x1+w1, y1, x2+w2, y2, x2-w2, y2)
       
    -- center line markings
    gfx.setColor(gfx.kColorBlack)
    if (sumct%4)==0 then
        local mw1, mw2 = .1*scale1, .1*scale2
        gfx.fillPolygon(x1-mw1, y1, x1+mw1, y1, x2+mw2, y2, x2-mw2, y2)
        end
    
    -- shoulders
    gfx.setColor(gfx.kColorWhite)
    if((sumct%2)==0) then gfx.setColor(gfx.kColorBlack) end
    local sw1,sw2= 0.2*scale1, 0.2*scale2

    drawTrapezium(x1-w1,y1,sw1,x2-w2,y2,sw2)
    --gfx.fillPolygon(x1-w1, y1, x1+sw1, y1, x2+sw2, y2, x2-sw2, y2)
    drawTrapezium(x1+w1,y1,sw1,x2+w2,y2,sw2) 
    --gfx.fillPolygon(x1-sw1, y1, x1+sw1, y1, x2+sw2, y2, x2-sw2, y2)]]--
   
end

function drawTrapezium(x1,y1,w1,x2,y2,w2)
    -- draw a trapezium by stacking horizontal lines
       local h=y2-y1 -- height
       local xd,wd=(x2-x1)/h,(w2-w1)/h -- width and x deltas
       local x,y,w=x1,y1,w1  -- current position
    local yadj=math.ceil(y)-y
    x+=yadj*xd
    y+=yadj
    w+=yadj*wd 
    while y<y2 do
     -- draw horizontal line
     gfx.drawLine(x-w,y,x+w,y)
           x+=xd
           y+=1
           w+=wd
       end
   end

function roadUpdate()
	--camz+=0.3 -- speed of the car
    camz+=playerCarSpeed
	if camz>1 then
	 camz-=1
	 camcnr,camseg=advance(camcnr,camseg)
	end
end


-- adds and removes speed value which is then used in roadUpdate()
function speedingPlayersCar()
    if pd.buttonIsPressed(pd.kButtonA) and playerCarSpeed<=0.6 then
        playerCarSpeed+=0.03 
    elseif playerCarSpeed>0 then playerCarSpeed-=0.01 
    elseif playerCarSpeed<=0 then playerCarSpeed=0
    end
end


-- car and cat drivers
local sprite = gfx.sprite.new(playerCarImage)
sprite:setZIndex(32767)
--sprite:setScale(1)
sprite:moveTo(250, 210)
sprite:add()

local i=0

function pd.update()
    gfx.clear()
    npcDrive()
    speedingPlayersCar()
    calculateRoad()
    roadUpdate()
	gfx.sprite.update()
	pd.drawFPS(0,0)
    i +=1
    if i==nil then pd.stop()end
end

I will be very thankful if any of You will check how updated version runs on the device :smile: In the meantime, author of original pico8 tutorial answered my questions about method for drawing NPC cars on the road. Here is what he proposed:

So basically the issue is that the road drawing currently only projects at the end of each segment. Which works fine for the background objects (trees etc), as they are aligned with the segment boundaries, but not for the NPC car, which can be midway along a segment.
You need to do a couple of things:
– There's a check in the addNPCsprite as to whether the NPC car is on the segment being drawn. I think this needs to be moved into the main drawing loop.
– Then when you detect the NPC car is on the segment, you need to calculate its 3D screen position.
– So you get the fractional distance along the segment, which I think is the fractional part of npcsprite.roadPosition (say call that "f")
– Then you need to calculate where that position along the current segment is, something like:
local npcx,npcy,npcz=x+fxd,y+fyd,z+f*zd
– Then call project that position: e.g.:
local npcpx,npcpy,npcscale=project(npcx,npcy,npcz)
– The pass that projected position to your addNPCsprite routine, which should (hopefully) now draw the car in the right place and scale.

It has a lot of sense for me and I though that I understand, and made a function which I initiated in the drawScreen() function just before that "Draw forward" loop.

function calculateNPCposition(npcSprite,x,y,z,xd,yd,zd,cnr,seg)
        local sumct=getsumct(cnr,seg)
        if not (((npcSprite.roadPosition-sumct)==0)) then return end
        local f = npcSprite.segmentFraction
        local npcx,npcy,npcz=x+f*xd,y+f*yd,z+f*zd
        npcSprite.npcpx,npcSprite.npcpy,npcSprite.npcscale=project(npcx,npcy,npcz)
end 

Also I modified npcSprite to be like this:

local npcSprite = {
    img = "images/car",
    siz={1.0,1.0},
    bgscl=0.011,
    roadPosition=1,
    segmentFraction=0,
    maxRoadSegments=0, -- moment when road position of the NPC should be reset to 1. A value is added in calculateRoad() function
    speed=1,
    npcx=0,
    npcy=0,
    npcscale=0,
}

And moving NPC function to be like this:

function npcDrive()
    if (npcSprite.roadPosition > npcSprite.maxRoadSegments) then npcSprite.roadPosition = 1 
    elseif npcSprite.segmentFraction >= 1 then npcSprite.segmentFraction -= 1 npcSprite.roadPosition += 1
    else npcSprite.segmentFraction += npcSprite.speed
    end
end

Unfortunately, after passed modified values to the drawing function I had weird results. So probably I messed something strongly somewhere. Maybe You have a clue how to fix it? I'm attaching current version of my game package in the post, but without this NPC car changes.
Koko & Kebi Racing (image wersion).zip (55.0 KB)