Building a Procedural Rope Effect in PlayBASIC

January 29, 2026

 


Building a Procedural 'Rope' Effect in PlayBASIC

Scanlines, Gouraud Shading, and Pixel Format Testing

At first glance, this demo looks like a twisting, three-dimensional rope floating over a smoothly scrolling, colour-banded background. It animates fluidly, reacts to user input, and even lets you switch pixel formats in real time.

What makes it interesting is how little machinery is actually involved.

There’s no 3D engine.

  • No meshes.
  • No textures.
  • No z-buffer.
  • Instead, the entire effect is built using 2D horizontal gouraud strips, some trigonometry, and a deliberate approach to colour interpolation. This makes it not only visually appealing, but also an excellent example of how PlayBASIC encourages efficient, low-level graphics thinking.


    What This Demo Is Designed to Show

    This program serves three purposes at once:

  • 1. Demonstrate how complex, organic effects can be built from simple 2D primitives
  • 2. Showcase PlayBASIC’s gouraud rendering and colour interpolation
  • 3. Stress-test different pixel formats (15, 16, 24, and 32-bit) using the same effect
  • You can switch pixel formats at runtime using F5–F8, and press Space to increase the number of edges used to construct the rope.


    Rendering to an FXImage (Why It Matters)

    Rather than drawing directly to the screen, the demo renders everything into an FXImage.

    This allows the program to:

  • Recreate the render target on demand
  • Change pixel depth without restarting
  • Ensure the exact same effect is rendered under different colour formats
  • When the user presses one of the function keys, the FXImage is deleted and recreated at the requested depth. The rendering logic itself doesn’t change — only the underlying pixel format does.

    This makes visual differences between colour depths immediately obvious, especially in gradients and gouraud shading.


    The Background: Colour Bands Built One Scanline at a Time

    The background consists of vertically repeating colour bands that slowly scroll upward. Each band blends smoothly into the next, creating a soft, continuous gradient.

    Pleasant Colours via Golden Ratio Stepping

    Instead of random colours, the palette is generated using a golden-ratio hue step. This ensures evenly spaced hues around the colour wheel and avoids clusters or muddy transitions.

     `NiceRGB()` advances the hue by ~0.618 each step, producing visually balanced colour sequences.
    

    Horizontal Gouraud Strips

    Each scanline is rendered using multiple horizontal gouraud strips, each with its own start and end colour. Because this happens line-by-line, the result is a smooth vertical gradient with excellent control over colour precision.

    This alone already provides a strong test of pixel formats:

  • Lower bit depths show banding earlier
  • Higher depths preserve smoother blends

  • The Rope: A 3D Illusion Built from 2D Spans

    The rope itself looks three-dimensional, but it isn’t.

    At its core, the rope is a rotating ring built from multiple edges arranged in a circle. Each edge is treated as a point in 3D space (X and Z), then projected into screen space.

     Screen X = `(X * focal_length) / Z`
    

    Each pair of adjacent edges becomes a horizontal span — a left and right X coordinate with a colour at each end.

    Only spans that face the camera (left X ≤ right X) are kept, which naturally removes back-facing segments without explicit clipping logic.


    Fake Lighting via Depth-Scaled Colour

    There’s no lighting model in this demo — and it doesn’t need one.

    Instead, colour brightness is scaled based on depth:

    > RGB values are multiplied by a constant and divided by Z
    

    As a segment moves farther away, its colour darkens. As it moves closer, it brightens. This simple relationship produces a convincing sense of depth and curvature, especially when combined with smooth interpolation.

    Because this happens before the colour is packed into the target pixel format, it also makes colour precision differences between formats very obvious.


    One Rope, Many Scanlines

    A key optimisation in this demo is that the rope geometry is calculated once per frame, not once per scanline.

    The projected spans are stored in an array, and then reused for every scanline. The illusion of movement comes from shifting those spans horizontally as the screen is drawn.

    This is where the rope gets its organic motion.


    Motion Through Layered Waves

    Instead of bending geometry, the demo applies several cosine-based offsets per scanline:

  • A slow, wide wave
  • A faster, tighter wave
  • Additional ripples layered on top
  • Each scanline adds these offsets together, producing a flowing, ribbon-like motion that feels far more complex than it really is.

     Geometry stays the same — only the X offset changes per scanline.
    

    This technique is extremely efficient and works beautifully with scanline-based rendering.


    Why Horizontal Gouraud Strips Are Ideal Here

    Every part of this demo is built around horizontal gouraud strips, and that’s very intentional.

    They are:

  • Cache-friendly
  • Deterministic
  • Perfectly suited to scanline effects
  • Consistent across all pixel formats
  • Using the same primitive for both the background and the rope ensures consistent behaviour and makes the demo easier to reason about.


    Scaling the Effect

    Pressing Space increases the number of rope edges. This:

  • Increases the number of spans
  • Adds visual complexity
  • Stresses colour interpolation further
  • Yet the core rendering logic remains unchanged. The effect scales naturally, which is a hallmark of a good procedural design.


    Final Thoughts

    This demo is a great example of PlayBASIC’s strengths:

  • Low-level control without unnecessary complexity
  • Powerful 2D primitives that can fake 3D convincingly
  • A rendering model that encourages efficient thinking
  • Most importantly, it shows that good visual effects are about understanding perception, not just throwing more technology at the problem.


    Full Source Code

    The complete PlayBASIC source code for this demo is included below for those who want to explore it in detail, experiment with it, or adapt the techniques for their own projects.

    Happy coding — and enjoy bending pixels the old-school way.



    	; This is pixel format test version of this demo
    
    	; Function keys f5/f6/f7/f8 to set the display depth
    	;  Space key to add more sides of objects
    
    
    	; open a tall strip like screen for the short
    	openscreen 800,1000,32,1
    
    	PositionScreen GetSCreenXpos(),0
    
    	; Load a new bigger font over the default font
       LoadFont "Courier New",1,48,0,8
       ; set the font rendering mode of font #1 to alpha blend
       fontdrawmode 1,1
    
    	; force a known randomize sequence by seeding  the generator
    	randomize 8262
    
       Global RopeEdges=3
    
    	Dim WaveRippleAngle#(10)
    
    	ScreenWidth  =GetScreenWidth()
    	ScreenHeight  =GetScreenHeight()
    
    	ScreenDepth=32
    	Screen=		NewFXImage(ScreenWidth,ScreenHeight)
    
    	Type RopeVerts
    		X1#,X2#, rgb1,rgb2
    	endtype
    
    
    
    
    For Tests=0 to 20
    
    
    	; init the palette
    	Dim Palette(RopeEdges+2)
    	For lp=0 to RopeEdges-1
    		Palette(lp) =NiceRGB(lp,0.25,0.90)
    	next
    	Palette(lp+1) =Palette(0)
    
    
       Dim RopeSegs(Ropeedges) as Ropeverts
    
    	Do
    
    			if functionkeys(5) then	ScreenDepth = 15
    			if functionkeys(6) then	ScreenDepth = 16
    			if functionkeys(7) then	ScreenDepth = 24
    			if functionkeys(8) then	ScreenDepth = 32
    
    			if GetIMageDepth(Screen) <> ScreenDepth
    					deleteimage Screen
    					Screen =GetFreeImage()
    					CReateFXImageEX Screen,GetScreenWidth(),GetScreenHeight(),ScreenDepth
      			endif
    
    			rendertoimage Screen
    
    
    		// backdrop
    		X1=ScreenWidth *0.00
    		X2=ScreenWidth *0.25
    		X3=SCreenWidth *0.50
    		X4=SCreenWidth *0.75
    		X5=SCreenWidth *1.00
    
    		lockbuffer
    			PaletteBandSize=50
    			ThisRGB1=Palette(0)
    			for ylp=0 to getscreenheight()-1
    
    				YOffset = mod(Ylp+ScrollY,PaletteBandSize*(RopeEdges-1))
    
    				Index = mod(yoffset/PaletteBandSize,(RopeEdges-1))
    				ThisRGB1=Palette(Index)
    				ThisRGB2=Palette(Index+2)
    
    				//  Scale /blend level between bands
    				Scale# = (Yoffset-(PaletteBandSize*Index)) / float(PaletteBandSize)
    
    				ThisRGB	 =  RgbAlphaBlend(ThisRGB1,ThisRGB2, Scale#*80)
    				ThisRGB50 = Rgbfade(ThisRGB,50)
    				ThisRGB20 = Rgbfade(ThisRGB,10)
    
    				GouraudStripH x1,ThisRGB,X2,ThisRGB50,Ylp
    				GouraudStripH x2,ThisRGB50,X3,ThisRGB20,Ylp
    				GouraudStripH x3,ThisRGB20,X4,ThisRGB50,Ylp
    				GouraudStripH x4,ThisRGB50,X5,ThisRGB,Ylp
    
    			next
    
    			DrawRope()
    
    		unlockbuffer
    		ScrollY=mod(ScrollY+1,PaletteBandSize*(RopeEdges-1))
    
    		renderimage Screen,0,0
    
    
    		Sync
    loop spacekey()
    
    RopeEdges++
    	flushkeys
    next
    
    	end
    
    
    
    Function DrawRope()
    
    	Static RadiusAngle
    	RadiusAngle=wrapangle(RadiusAngle +1)
    
       radius=250+cos(RadiusChange)*50
       ScReenHeight=GetScreenHeight()
    	basex=GetScreenwidth()/2
    
    	//  step the global angles
    		AngleStep=2
    		WaveAngle#= WaveRippleAngle#(1)
    		WaveAngle2#= WaveRippleAngle#(2)
    		WaveAngle3#= WaveRippleAngle#(3)
    		WaveAngle4#= WaveRippleAngle#(4)
    
    		WaveRippleAngle#(1)=wrapangle(WaveAngle#,0.7*AngleStep)
    		WaveRippleAngle#(2)=wrapangle(WaveAngle2#,1.2*AngleStep)
    		WaveRippleAngle#(3)=wrapangle(WaveAngle3#,2.1*AngleStep)
    		WaveRippleAngle#(4)=wrapangle(WaveAngle4#,-0.4*AngleStep)
    
    	EdgeAngleStep#=360.0/(RopeEdges)
    
    	BaseZ=1000
    
    	Segments=0
    		static BaseAngle#
    
    		ThisRGB1 = Palette(RopeEdges)
    
    		For lp=0 To RopeEdges-1
    			ThisRGB2 = Palette(lp+1)
    
    			Angle#=BaseAngle#+(EdgeAngleStep#*lp)
    			Angle2#=Angle#+EdgeAngleStep#
    
    
    			X1#=Cosradius(angle#,Radius)
    			X2#=CosRadius(angle2#,Radius)
    			Z1#=basez+SinRadius(angle#,Radius)
    			Z2#=basez+SinRadius(angle2#,Radius)
    
    			Px1#=((X1#*400)/z1#)
    			Px2#=((X2#*400)/z2#)
    
    			If pX1#<=pX2#
    
    				R=RgbR(ThisRgb1)
    				G=RgbG(ThisRgb1)
    				B=RgbB(ThisRGB1)
    
    				R1=(R*500)/z1#
    				G1=(G*500)/z1#
    				B1=(B*500)/z1#
    				R=RgbR(ThisRgb2)
    				G=RgbG(ThisRgb2)
    				B=RgbB(ThisRGB2)
    
    				R2=(R*500)/z2#
    				G2=(G*500)/z2#
    				B2=(B*500)/z2#
    
    				RopeSegs(Segments).x1=px1#
    				RopeSegs(Segments).x2=px2#
    				RopeSegs(Segments).rgb1=rgb(r1,g1,b1)
    				RopeSegs(Segments).rgb2=rgb(r2,g2,b2)
    				inc segments
    			EndIf
    
    			ThisRGB1=ThisRGB2
    		Next
    
    	dec segments
    
    	For ThisScanLine=0 To ScreenHeight-1
    		Xoffset=basex+cosradius(wAVEangle#,50)
    		Xoffset=Xoffset+cosradius(wAVEangle2#+wAVEangle#,40)
    		Xoffset=Xoffset+cosradius(wAVEangle3#,80)
    		Xoffset=Xoffset+cosradius(wAVEangle4#,30)
    
    		For lp=0 to Segments
    				GouraudStripH Xoffset+RopeSegs(lp).x1,RopeSegs(lp).rgb1,Xoffset+RopeSegs(lp).x2,RopeSegs(lp).rgb2,ThisScanline
    	;			GouraudStripV Xoffset+RopeSegs(lp).x1,RopeSegs(lp).rgb1,Xoffset+RopeSegs(lp).x2,RopeSegs(lp).rgb2,ThisScanline
    		next
    		waveangle#	+=	0.15
    		waveangle2#	+=	0.25
    		waveangle3#	+=	0.35
    		waveangle4#	+= 0.33
    
    	Next
    
     	BaseAngle#++
    EndFunction
    
    
    
    
    ; ===============================================================
    ;   Golden Ratio based pleasant random colors
    ;   - Each call with incrementing index gives nicely spaced hues
    ; ===============================================================
    	constant GoldenRatio# = 0.6180339   ; ˜ (v5 - 1) / 2
    
    Function NiceRGB(Index=0, Saturation#=0.75, Value#=0.88)
    	 Local Hue#
    
        ; Start from a random base hue, then walk by golden ratio
        ; (mod 1.0 to wrap around colour wheel)
        Hue# = mod(Rnd#(1) + (Index * GoldenRatio#),1)
        Hue# = Hue# - Floor(Hue#)           ; fractional part only (mod 1)
    
        ; Optional: You can randomise S/V a little for more variety
         Saturation# = Saturation# + RndRange#(-0.15, 0.15)
         Value#      = Value#      + RndRange#(-0.1,  0.1)
        ; Clamp them
        If Saturation# < 0.4 Then Saturation#=0.4
        If Saturation# > 0.98 Then Saturation#=0.98
        If Value#      < 0.5 Then Value#=0.5
        If Value#      > 0.98 Then Value#=0.98
    
    	#if playbasicversion >=165
    	    Result = HSV2RGB(Hue#, Saturation#, Value#)
    	#else
    	    	Result=rndrgb()
    	#Endif
    EndFunction Result
    
    
    
    
    
    Function RenderImage(ThisImage,xpos,ypos,Mode=false)
    	if GetIMageStatus(ThisIMAGE)
    
    			inkmode 1+2048
    			boxc 00,18,275,112,true,$a0a0a0
    			inkmode 1
    
    
    			Xpos+=10
    			Ypos+=20
    			ink $80fFFFFF
    			Text XPos,Ypos		,"  Fps:"+Str$(fps())
    			Text XPos,Ypos+40	,"Depth:"+Str$(GetIMageDepth(ThisIMage))
    
    			rendertoscreen
    			lockbuffer
    				drawimage ThisImage,0,0,false
    			unlockbuffer
    
    	endif
    EndFunction


    Is BASIC Interpreted or Compiled? And Can It Compete With C?

    January 14, 2026

     

    Logo

    Is BASIC Interpreted or Compiled? And Can It Compete With C?

    This question comes up a lot, especially from programmers who cut their teeth on early home computers:

     *Is BASIC interpreted or compiled? And if it’s compiled, can it ever approach the performance of C or C++?*
    

    The short answer is: yes, it can—but the longer answer is far more interesting.


    From BASIC to Native Code

    When people talk about “compiled BASIC,” they often imagine a direct leap from BASIC source code to machine code. In practice, that’s rarely how modern systems work.

    It’s not uncommon—for BASIC or any language—to compile into an intermediate form first. Historically this might be bytecode, but today it could also be an abstract syntax tree or another IR (intermediate representation). From there, many systems transpile into C, LLVM IR, or another mature backend.

    Why does this matter?

    Because those backends are battle-tested. They encapsulate decades of optimization knowledge, and by targeting them, a BASIC compiler can automatically benefit from highly sophisticated optimization passes—without reinventing the wheel.


    Generating Native Code: Several Paths

    There are a few common approaches BASIC compilers have taken over the years:

  • Direct machine code generation
  • Possible, but generally not recommended unless you enjoy pain.

  • Assembly generation
  • A more common approach: translate bytecode into assembly, then feed it through an embedded assembler.

  • C or modern IR backends
  • Increasingly popular, and often the most pragmatic choice.

    The good news is that even a naive bytecode-to-assembly translator can produce code that runs quite well. At its simplest, this is often a near line-for-line mapping of bytecode instructions into native instructions.

    That kind of output tends to hit memory a lot—but even so, it already executes far faster than an interpreter.


    Where Performance Is Really Won (or Lost)

    Here’s where things get interesting.

    Most people think performance comes down to low-level micro-optimizations. Those matter—but they’re not where the biggest gains usually come from.

    The real performance cliff is often created much earlier, by language and runtime design decisions.

    Memory Access Matters

    A naive translation model tends to generate excessive memory loads and stores. Simply removing redundant memory accesses can result in large performance wins.

    Many BASIC compilers already perform instruction-level optimizations at the bytecode stage:

  • Removing redundant loads and stores
  • Eliminating dead code
  • Collapsing simple instruction sequences
  • Even modest cleanup here can produce surprisingly large gains.


    The Hidden Cost of “Convenience”

    One of the classic examples is string handling.

    In the BASIC world, string systems are often built from off-the-shelf components designed for flexibility and safety, not speed. They work—and they work well—but they can absolutely destroy performance if you’re not careful.

    This isn’t a BASIC-only problem. It’s a reminder that:

  • Performance isn’t just about the compiler.
  • It’s about how the language chooses to represent and manage data.

  • Can BASIC Match C or C++?

    The uncomfortable truth for some people is this:

    Yes—BASIC compilers can generate code that rivals (or even beats) the output of some C compilers.

    Remember:

  • Not all C compilers are equal
  • Not all C code is well-written
  • And not all optimizers are created equal
  • That said, most BASIC compilers don’t aim for absolute peak performance. Their goals are often different: approachability, safety, rapid development, or portability.

    But the idea that there’s some enormous, unbridgeable performance chasm between BASIC and C is largely a relic of the interpreter era.

    A lot has changed since then.


    Final Thoughts

    The real takeaway is this:

  • BASIC doesn’t have to be slow
  • Compilation strategies matter
  • Runtime design matters even more
  • Modern compilation techniques have blurred the old lines. Performance today is less about what language you use and more about how that language is implemented.

    And that’s a far more interesting conversation than “interpreted vs compiled” ever was.


    Manual Base Conversion in PlayBASIC

    December 08, 2025

     

    Logo

    Converting a decimal number stored as a string into Binary, Octal and Hexadecimal


    In this tutorial we are going to manually convert a decimal number stored inside a string into:

    • Base 2 (Binary)

    • Base 8 (Octal)

    • Base 16 (Hexadecimal)

    This example avoids built-in conversion commands on purpose, so beginners can see how the process works internally.


    Example Output Usage

    s$="87654321"
    print s$ +"="+ ConvertTo(S$,2)
    print s$ +"="+ ConvertTo(S$,8)
    print s$ +"="+ ConvertTo(S$,16)
    print ""
    
    s$="-12345678"
    print s$ +"="+ ConvertTo(S$,2)
    print s$ +"="+ ConvertTo(S$,8)
    print s$ +"="+ ConvertTo(S$,16)
    print ""
    
    s$="255"
    print s$ +"="+ ConvertTo(S$,2)
    print s$ +"="+ ConvertTo(S$,8)
    print s$ +"="+ ConvertTo(S$,16)
    print ""
    
    Sync
    waitkey

    Step 1: Manually Converting the String to an Integer

    Before we can convert to another base, we must first turn the string into an actual integer value.

    This is done digit-by-digit using basic decimal math.

    Function ConvertTo(S$,Base)
    rem assumed 32bit integers
    Total =0
    Negate=0
    
    for lp=1 to len(s$)
        Total=Total*10
        ThisCHR = asc(mid$(s$,lp))
    
        if ThisChr = asc("-") then Negate=1   
    
        if ThisChr >= asc("0") and ThisCHR<=asc("9")
            Total=Total+(ThisCHR-Asc("0"))       
        endif
    next
    
    if Negate then Total *= -1   

    What’s happening here?

    • Each digit is multiplied into place using base-10 math

    • `ASC()` is used to convert characters into numeric values

    • The minus symbol `"-"` is detected and applied at the end

    This is essentially how a basic `Val()` function works internally.


    Step 2: Preparing for Base Conversion

    Each output base is selected using bit grouping.

    select base
    case 2
    Shift=1
    Characters$="01"
    case 8
    Shift=3
    Characters$="01234567"
    case 16
    Shift=4
    Characters$="0123456789ABCDEF"
    endselect

    Why these values?

    • Binary uses 1 bit per digit

    • Octal uses 3 bits per digit

    • Hexadecimal uses 4 bits per digit


    Step 3: Bitwise Conversion Loop

    Now the number is converted using bit masking and bit shifting.

    if Shift
    Mask    = (2^Shift)-1
    Digits = 32 / Shift
    
        For lp=0 to Digits-1
            ThisCHR = Total and MASK
            Result$ = Mid$(Characters$,ThisChr+1,1) + Result$
            Total = Total >> Shift                               
        next
    endif
       
    
    EndFunction Result$

    Important notes:

    • Output is a fixed 32-bit representation

    • Leading zeros are expected and correct

    • Negative numbers are shown using two’s complement

    The result string is built from right to left because the least-significant bits are processed first.


    Summary

    This tutorial demonstrates:

    • Manual string → integer conversion

    • Decimal positional maths

    • Bit masking and shifting

    • Why binary, octal and hex exist

    • How CPUs naturally represent numbers

    This approach may not be the shortest, but it clearly shows how the conversion works under the hood — making it ideal for learners.

    Complete Code:

        s$="87654321"
        print s$ +"="+ ConvertTo(S$,2)
        print s$ +"="+ ConvertTo(S$,8)
        print s$ +"="+ ConvertTo(S$,16)
        print ""
    
        s$="-12345678"
        print s$ +"="+ ConvertTo(S$,2)
        print s$ +"="+ ConvertTo(S$,8)
        print s$ +"="+ ConvertTo(S$,16)
        print ""
    
        s$="255"
        print s$ +"="+ ConvertTo(S$,2)
        print s$ +"="+ ConvertTo(S$,8)
        print s$ +"="+ ConvertTo(S$,16)
        print ""
    
        Sync
        waitkey
       
    
    Function ConvertTo(S$,Base)
        rem assumed 32bit integers
        Total =0
        Negate=0
        for lp=1 to len(s$)
            Total    =Total*10
            ThisCHR = mid(s$,lp)
            if ThisChr = asc("-") then Negate=1   
            if ThisChr >= asc("0") and ThisCHR<=asc("9")
                Total=Total+(ThisCHR-Asc("0"))       
            endif
        next
        if Negate then Total *= -1   
    
        Characters$    ="0123456789ABCDEF"
        select base
                    case 2
                        Shift=1   
                    case 8
                        Shift=3   
                    case 16
                        Shift=4   
        endselect   
       
        if Shift
            Mask        =(2^Shift)-1
            For lp=1 to 32 / Shift
                    ThisCHR = Total and MASK
                    Result$ = Mid$(CHaracters$,ThisChr+1,1) +Result$
                    Total = Total >> Shift                               
            next
        endif
           
    EndFunction Result$