Struggling with Pseudo3D racing game engine

OMG, I've seen your game on itch when I was doing research and actually, I thought "Oh, so psuedo3D is possible here, let's do a racer then" :joy:

1 Like

Guess this is fate then xD
Best of luck to ya! Looking forward to whatever you code up next :wink:

2 Likes

Thanks! I will keep you updated. Also, I think I will also try to push that code someday to Love2D and make a mobile Pseudo3D racer. At last my crappy code won't be so much problem for modern smartphones :smile:

1 Like

If you’re still looking for help with the sprite performance issue, here are a few technical pointers from things I noticed in your code. These will probably end up being pretty important regardless of whether you continue this project or work on a smaller one.

  1. When you pass a file path to a sprite or image initializer, it reloads that file from disk. It looks like right now your code does that for every sprite, every frame! Generally, it’s best to only load each image file once at the start and then reference that everywhere you need it. That way you aren’t constantly reading from disk and also aren’t filling up RAM with multiple copies of the same image. This one is going to be a performance killer on any platform, not just playdate.

  2. Playdate SDK Sprites are meant to persist their state and have the sprite system automatically call their :draw() method when necessary. This is different from pico8 etc where a sprite is basically just a graphic. I think playdate.graphics.image is more akin to what pico8 calls a sprite. I think for any pseudo-3d game, you’re going to want to have your code calling the :draw() method of images, instead of creating a fresh set of sprites every frame just to delete them when the next frame starts.

2 Likes

Thank you for these amazing insights! @daprice This is very helpful for my future project also, as I didn't think abut reading sprites from disk though. I guess its a noob mistake :sweat_smile: I was thinking now about reducing amount of sprites, and even rendering the game in half of PD resolution (like mode7 demo project does) but these two advices can be very handy. I understand first one fully, but I will need to dig more on the second hint:

– i should use images instead of sprites
– load them once and then refer only to variable containing them
– use "playdate.graphics.image:drawScaled(x, y, scale, [yscale])" for drawing them scaled

Is that correct? And anyway, do you have any idea how to avoid this blinking for NPC drivers? :thinking:

Yeah that sounds like a good starting point. At least, that’s how I would approach it, but it may not be the only (or even best) way.

One problem you might run into is that scaling images to non-integer sizes is expensive/slow on the playdate (this is a playdate-specific limitation that other platforms with GPUs don’t really have). I think other devs doing pseudo 3d stuff usually end up having to create multiple pre-scaled versions of their images, then in place of drawScaled they pick the closest one from a table and call its draw(x, y). But I think it’s worth a try using drawScaled to see how it performs, then test and go from there.

1 Like

Thank you once again! I've seen using pre-scalled stuff on platforms like Game Boy or NES, which didn't support sprite scalling. In this approach I guess I would have first use print() for my "scale" variable to predefine which should be pick to draw.

I still don't have any idea for moving NPCs though. I mean currently they are just redrawn in new cordinates, when they get into new road segments. And I know I should implement additional math in scaling them, which will consider not only their road segment but but also their placement related to the player.

This would be easier if I would fully understand the math from pico8 tutorial but currently im like ~60% of knowing whats happening there :sweat_smile: I mean I understand the concept of "cursor" going through segments of the road table and drawing different trapezoids considering fake3D projections math, but I still don't get how its possible that moving camera can move fluidly through road (not one segment per step) but my NPC driver can't and he teleports to each segment one by one.

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)

Yep. That did the trick. This now runs on solid 19-22 fps.
Way better than before!

1 Like

Great! And thank you for testing! Also huge thanks for @daprice for that hint! This is giving me hope :smile: Of course there are probably ways to optimize more, but on the basic level its cool. Maybe I wont implement hills or some other advanced effects to save some power, but now I need to focus on that NPC blinking problem. I cant figure it out though :sweat_smile:

1 Like

Jesus Christ, I think I did it :hear_no_evil: Now it's blinking much less.
Koko & Kebi Racer with smooth NPC drivers

I think it's time for more optimization and the rest of the stuff - AI, collision detection, etc. I guess more obstacles will appear :sweat_smile:

Here is the recent package If someone wants to try it on the device:
Karting - images - NPC car.zip (54.6 KB)

3 Likes

Phenomenal progress! Well done — keep it up!

1 Like

Thank you! :pray:t2: My stubbornness is one thing, but this great community support is that, what helps me to keep going. And after all it gives a lot of satisfaction. I always thought that I’m too dumb for programming and game development, even while I finished It studies and work everyday as an UX designer :sweat_smile: It turns out that I was just procrastinating.

3 Likes