Tutorial - Twin Stick Shooter

28-Apr-2016
Tom Mulgrew

This tutorial

In this tutorial we will create a "twin-stick shooter" in Basic4GL Mobile and run it on an Android phone or tablet.

A twin-stick shooter is a 2D shooting game where you use two directional controllers ("sticks") - one for moving and one for aiming. We will relax the definition a little bit for the PC version and use the keyboard to move and the mouse to aim. On mobile the player will use two "sticks", but they will be virtual on-screen ones.

Requirements

You will need:

You can get both from http://basic4gl.net/mobile

This tutorial assumes you already know how to transfer a Basic4GL Mobile game onto your phone/tablet. If not, I suggest you work through this tutorial first.

Prepare your folder

Create a folder on your PC for the game source code and assets. If you're using a synchronised folder (e.g. Google Drive) use that one.

Find the Basic4GL demo programs folder (Start->All Apps->Basic4GL Mobile->Demo Programs) and copy all of the subfolders (gfx, music, sounds, standard) into your folder.

Initial program

We'll start by creating the player sprite and setting up a simple main loop.

Run Basic4GL Mobile. Key in the following program, and save it in your folder as "twinstickshooter.bglm":

' Types
type TPlayer
	sprite
end type

' Game data
dim player as TPlayer

' Textures
dim shipTexture = LoadTex("gfx/F117.png")

' Setup screen
TextMode(TEXT_BUFFERED)

' Setup game
player.sprite = NewSprite(shipTexture)
SprSetSize(32, 32)
SprSetPos(SpriteAreaWidth() / 2, SpriteAreaHeight() / 2)

' Main loop
while true
	WaitTimer(20)
	DrawText()
wend

Run the program and you should see a triangular player sprite in the middle of a blank screen.

The type TPlayer defines a data type for storing the player information. Currently there's just one field sprite which holds the handle to the player's sprite. Defining a type helps keep all the player information together, and will be useful when we later need to add other fields, like score, lives, shooting timer etc.

We then declare a player variable with dim player as TPlayer

We load a .png image for the player's ship with LoadTex and assign it to a variable shipTexture for use later.

TextMode(TEXT_BUFFERED) sets the drawing mode to "buffered", which means sprites and text will only be drawn when DrawText() is called. In the default mode, TEXT_SIMPLE, the screen is redrawn whenever you print some text or move a sprite, which is a good default for simple programs but causes way too many redraws in a game that has multiple moving sprites.

We then create the player's sprite with NewSprite passing it the ship texture handle to specify what it should look like, and set its size with SprSetSize and position with SprSetPos. These sprite commands affect the current sprite, which after calling NewSprite is the newly created sprite. We can also set the current sprite by passing its handle to the BindSprite command, which we will see later.

We've also used SpriteAreaWidth and SpriteAreaHeight to get the dimensions of the "sprite area". By default the sprite area is 640x480 units, where (0,0) is the coordinate of the top left corner of the screen and (640,480) is the bottom right hand corner. If the screen is a different resolution the sprites will be stretched and scaled accordingly. It is often desirable to resize the sprite area to match the device resolution so that you can work in pixel coordinates (which you can do with ResizeSpriteArea(DeviceWidth(), DeviceHeight())). But for this tutorial we will just use the default 640x480.

Finally we create the main loop using a while / wend loop, in which we call DrawText() every 20 milliseconds, using WaitTimer(20) to regulate the timing.

About sprites

Sprites are persistent on screen objects with a texture and position (and other properties).

Once a sprite has been created, it will be drawn each time the screen is updated with DrawText() (which really means "draw text and sprites"), until the sprite is deleted.

Sprites are how you display 2D content in Basic4GL Mobile.

See "Help->Sprite library guide" in the Basic4GL Mobile menu for more.

Adding player movement

Let's make the player move and turn.

Update the program as follows:

include standard/gameinput.inc

' Types
type TPlayer
	sprite
end type

' Game data
dim player as TPlayer

' Working variables
dim gameInput as TTwinStickInput

' Textures
dim shipTexture = LoadTex("gfx/F117.png")

' Setup screen
TextMode(TEXT_BUFFERED)

' Setup game
player.sprite = NewSprite(shipTexture)
SprSetSize(32, 32)
SprSetPos(SpriteAreaWidth() / 2, SpriteAreaHeight() / 2)

' Main loop
while true
	WaitTimer(20)
	UIBegin()

	' Get input
	gameInput = GetTwinStickInput(SprPos(player.sprite))

	' Move player
	BindSprite(player.sprite)
	SprSetVel(gameInput.dir# * 5)
	if gameInput.isAiming then
		SprSetAngle(gameInput.aimAng#)
	endif
	if SprX() < 0 then SprSetX(0) endif
	if SprX() > SpriteAreaWidth() then SprSetX(SpriteAreaWidth()) endif
	if SprY() < 0 then SprSetY(0) endif
	if SprY() > SpriteAreaHeight() then SprSetY(SpriteAreaHeight()) endif	

	AnimateSprites()
	UIEnd()
wend

Run the program and move the player around with the arrow keys. Use the mouse to move the crosshair, and see how the player's sprite always aims at it. This is the basis of a twin-stick shooter.

If you want you can also try transferring it onto your Android phone or tablet. Instead of a keyboard and a mouse you should see two on-screen "joysticks". Touch the left side of the screen to move the player, and the right side to aim.

Lets analyse what's happening.

include standard/gameinput.inc is used to include another file in the current program. This means the program will be compiled as if it contained all the code in standard/gameinput.inc. This file contains routines and data types for game input, such as the TTwinStickInput data type and the GetTwinStickInput function which we use further down.

The standard/gameinput.inc file contains BASIC program code with useful routines for game input. You can view it (by clicking on the "include" instruction in the editor) and navigate through it, debug into the code at runtime or even change it if you want (although I suggest leaving it unchanged for this tutorial!). The important take-away here is that there's nothing special or magical about it, it's just BASIC code.

In the data section we declare a variable to hold the twin-stick game input with dim gameInput as TTwinStickInput.

The remaining new code is inside the main loop. First we've replaced DrawText() with UIBegin() and UIEnd() calls. The game input routines use an Immediate Mode GUI to display the on-screen controls. This type of GUI makes it easy add on screen UI controls with minimum code (one line in this case), and the IMGUI handles drawing and managing the controls behind the scenes for you, such as the cross hair you see when running on PC, or the on screen joysticks on mobile. In this IMGUI implementation you must wrap all your GUI code in UIBegin and UIEnd calls, so that the GUI knows when you've finished and are ready to update the screen. Note that UIEnd calls DrawText for us, which is why we removed the DrawText() line from our program.

GetTwinStickInput( SprPos(player.sprite)) fetches the player's input, which we store in the gameInput variable we declared earlier. GetTwinStickInput needs to know the player's position on the screen so that it can figure out the angle required to aim the player at the crosshair. We get this with SprPos(player.sprite) which returns a 2 element array of numbers which we treat as an (X,Y) vector.

TTwinStickInput is a data type defined in standard\gameinputparams.inc (which is included by standard\gameinput.inc), as:

' Returns twin stick input from UITwinStickInput- and GetTwinStickInput- functions()
type TTwinStickInput
	dir#(1)				' Movement direction (<= unit length)
	aim#(1)				' Aiming direction (unit length or zero)
	aimAng#				' Aiming angle (degrees)
	isAiming			' True if aiming
	isShooting			' True if shooting
end type

This gives us the information necessary to move and aim the player:

BindSprite(player.sprite) makes the player's sprite the current sprite (so that SprSetVel and SprSetAngle will be applied to that sprite).

SprSetVel(gameInput.dir# * 5) then sets the player's sprite's velocity (which is added to the sprite's position every time AnimateSprites() is called). gameInput.dir# is a vector that is never longer than 1 unit, so the player will move a maximum of 5 units per game frame.

We set the player direction with SprSetAngle(gameInput.aimAng#), which rotates the player's sprite to face the crosshair. We only do this if the gameInput.isAiming flag is set, which indicates that the player is aiming. Note that on PC the player is always considered to be "aiming", whereas on mobile the player is only aiming if they are touching the right hand side on-screen joystick.

The remaining code simply prevents the player sprite from leaving the screen, by accessing the player's X and Y position directly with SprX, SprY and SprSetX, SprSetY.

And finally AnimateSprites() automatically moves all the sprites on screen by their velocities (such as the player's sprite, by the velocity set with SprSetVel above).

Shooting

To be a twin stick shooter we must be able to shoot.

This requires a bit more code. We must create the bullet's sprite when the player fires it, keep track of all the bullets on screen, and delete their sprites when they leave the screen.

We'll use a data type to specify what we need to store per bullet, and an array to store the actual bullet data.

Update the program as follows:

include standard/gameinput.inc

' Types
type TPlayer
	sprite
	reloadCounter
end type

type TBullet
	sprite
	isActive
end type

' Constants
const MAXBULLETS = 100
const FIRERATE = 5

' Game data
dim player as TPlayer
dim bullets(MAXBULLETS) as TBullet

' Working variables
dim gameInput as TTwinStickInput
dim i

' Textures
dim shipTexture = LoadTex("gfx/F117.png")
dim bulletTexture = LoadTex("gfx/FB01.png")

' Sound effects
dim shootSound = LoadSound("sounds/laser.wav")

' Setup screen
TextMode(TEXT_BUFFERED)

' Setup game
player.sprite = NewSprite(shipTexture)
SprSetSize(32, 32)
SprSetPos(SpriteAreaWidth() / 2, SpriteAreaHeight() / 2)

' Main loop
while true
	WaitTimer(20)
	UIBegin()

	' Get input
	gameInput = GetTwinStickInput(SprPos(player.sprite))

	' Move player
	BindSprite(player.sprite)
	SprSetVel(gameInput.dir# * 5)
	if gameInput.isAiming then
		SprSetAngle(gameInput.aimAng#)
	endif
	if SprX() < 0 then SprSetX(0) endif
	if SprX() > SpriteAreaWidth() then SprSetX(SpriteAreaWidth()) endif
	if SprY() < 0 then SprSetY(0) endif
	if SprY() > SpriteAreaHeight() then SprSetY(SpriteAreaHeight()) endif

	' Fire bullets
	if player.reloadCounter > 0 then
		player.reloadCounter = player.reloadCounter - 1
	endif
	if player.reloadCounter = 0 and gameInput.isShooting then
		for i = 1 to MAXBULLETS
			if not bullets(i).isActive then
				PlaySound(shootSound)

				' Create sprite
				bullets(i).sprite = NewSprite(bulletTexture)
				SprSetPos(SprPos(player.sprite))
				SprSetSize(12, 12)
				SprSetVel(gameInput.aim# * 15)

				bullets(i).isActive = true

				' Set reload counter
				player.reloadCounter = FIRERATE

				' Exit loop
				i = MAXBULLETS
			endif
		next
	endif

	' Update bullets
	for i = 1 to MAXBULLETS
		if bullets(i).isActive then
			BindSprite(bullets(i).sprite)			
			if SprBottom() < 0 or SprTop() > SpriteAreaHeight() or SprRight() < 0 or SprLeft() > SpriteAreaWidth() then
				DeleteSprite(bullets(i).sprite)
				bullets(i).isActive = false
			endif
		endif
	next

	locate 0, 0
	print "Sprites: "; SpriteCount()

	AnimateSprites()
	UIEnd()
wend

Run the program and click the mouse to shoot a stream of bullets (or use the right on-screen joystick on Android). You can move and shoot at the same time, which is a defining feature of twin-stick shooter gameplay.

Once again, let's analyse the new code:

We've added a reloadCounter to the TPlayer type, so that we can limit the rate of fire. When the mouse button (or on-screen joystick) is pressed we only want to create one bullet every 5 game frames, so we use this counter to count down to when we're next allowed to fire.

We've defined the TBullet type to specify what is stored per bullet. As with the player we store a sprite handle. We also store a flag isActive to indicate whether that particular bullet is active. We need this because the bullet array will have 100 entries but we won't always have 100 bullets on screen, so we will need to know which array entries correspond to active on-screen bullets (isActive = true) and which ones are inactive (isActive = false) and ready to be used when the player fires the next bullet.

We declare some constants: MAXBULLETS = 100 and FIRERATE = 5. Using constants makes it easy to tweak and tune the program later, rather than having to hunt around and change numbers scattered through the program.

dim bullets(MAXBULLETS) as TBullet allocates a 100 element array of TBullet elements, to store our bullets. We also need a working variable i to use as a loop counter.

Note: Actually the array has 101 entries, from 0 to 100 inclusive, but for simplicity we've chosen to ignore the 0 index element in this tutorial. (Computer memory is cheap these days.)

We load another texture (gfx/FB01.png) to use for the bullet sprites, and a sound effect with LoadSound("sounds/laser.wav"). This also returns a handle, but for a sound effect. It will be passed to PlaySound to play the sound.

Inside the loop we have some logic to manage the reload counter player.reloadCounter. Essentially it counts down until it reaches 0 at which point the player is allowed to fire. After the player fires, we set it back to FIRERATE, so that the player cannot shoot again for that many frames.

The gameInput.isShooting flag tells us whether the player is pressing the fire button (the mouse button on PC, or the on-screen joystick on Android). If they are and they have reloaded then we create a bullet.

To create a bullet we must first find an inactive bullet by searching through the bullets array. Once we find one we play the shooting sound PlaySound(shootSound), then create a NewSprite using the bulletTexture, set its position to the player's position and set its velocity to gameInput.aim# * 15, which if you recall is a 2 element array that represents the direction the player is aiming as an X,Y vector. This vector is always 1 unit long, so multiplying by 15 means the bullet will always travel 15 every time AnimateSprites is called.

We also set the bullet's isActive flag so we know that this element represents an active on-screen bullet.

Then we exit the search loop by setting i = MAXBULLETS, so that it doesn't keep searching for another inactive bullet.

The next section of the main loop tracks the active bullets and removes them once they go off screen.

The SprTop, SprBottom, SprLeft and SprRight functions track the coordinates of the bounding rectangle around the sprite. We use these to detect when the sprite is fully off the screen. When it is we use DeleteSprite to remove the sprite from the screen and and set the bullet's isActive flag back to false.

Finally at the bottom we display the number of active sprites, using the SpriteCount function. This is for debugging purposes, so we can check that the bullet sprites are actually being deleted properly.

Enemies

Next we need some enemies to shoot at.

These will be implemented much like the bullets, in that they will be stored in an array, each with a sprite and an "active" flag. The enemies will be spawned in waves several seconds apart and will accelerate towards the player. To make it interesting we will decrease the delay between waves as the game goes on and make each wave progressively larger.

Update the program as follows:

include standard/gameinput.inc

' Types
type TPlayer
	sprite
	reloadCounter
end type

type TBullet
	sprite
	isActive
end type

type TEnemy
	sprite
	isActive
end type

' Constants
const MAXBULLETS = 100
const MAXENEMIES = 50
const FIRERATE = 5

' Game data
dim player as TPlayer
dim bullets(MAXBULLETS) as TBullet
dim enemies(MAXENEMIES) as TEnemy

' Working variables
dim gameInput as TTwinStickInput
dim i, j
dim nextWaveTimer# = 6, waveDelay# = 10
dim waveSize = 7

' Textures
dim shipTexture = LoadTex("gfx/F117.png")
dim bulletTexture = LoadTex("gfx/FB01.png")
dim enemyTexture = LoadTex("gfx/TripodFoot.png")

' Sound effects
dim shootSound = LoadSound("sounds/laser.wav")
dim explosionSound = LoadSound("sounds/explos.wav")

' Setup screen
TextMode(TEXT_BUFFERED)

' Setup game
player.sprite = NewSprite(shipTexture)
SprSetSize(32, 32)
SprSetPos(SpriteAreaWidth() / 2, SpriteAreaHeight() / 2)

' Main loop
while true
	WaitTimer(20)
	UIBegin()

	' Get input
	gameInput = GetTwinStickInput(SprPos(player.sprite))

	' Move player
	BindSprite(player.sprite)
	SprSetVel(gameInput.dir# * 5)
	if gameInput.isAiming then
		SprSetAngle(gameInput.aimAng#)
	endif
	if SprX() < 0 then SprSetX(0) endif
	if SprX() > SpriteAreaWidth() then SprSetX(SpriteAreaWidth()) endif
	if SprY() < 0 then SprSetY(0) endif
	if SprY() > SpriteAreaHeight() then SprSetY(SpriteAreaHeight()) endif

	' Fire bullets
	if player.reloadCounter > 0 then
		player.reloadCounter = player.reloadCounter - 1
	endif
	if player.reloadCounter = 0 and gameInput.isShooting then
		for i = 1 to MAXBULLETS
			if not bullets(i).isActive then
				PlaySound(shootSound)

				' Create sprite
				bullets(i).sprite = NewSprite(bulletTexture)
				SprSetPos(SprPos(player.sprite))
				SprSetSize(12, 12)
				SprSetVel(gameInput.aim# * 15)

				bullets(i).isActive = true

				' Set reload counter
				player.reloadCounter = FIRERATE

				' Exit loop
				i = MAXBULLETS
			endif
		next
	endif

	' Update bullets
	for i = 1 to MAXBULLETS
		if bullets(i).isActive then
			BindSprite(bullets(i).sprite)			
			if SprBottom() < 0 or SprTop() > SpriteAreaHeight() or SprRight() < 0 or SprLeft() > SpriteAreaWidth() then
				DeleteSprite(bullets(i).sprite)
				bullets(i).isActive = false
			endif
		endif
	next

	' Make enemies
	nextWaveTimer# = nextWaveTimer# - (20.0 / 1000.0)			' 20 ms per game frame
	if nextWaveTimer# <= 0 then

		' Make enemies
		for j = 1 to waveSize
			for i = 1 to MAXENEMIES
				if not enemies(i).isActive then

					' Spawn enemy
					enemies(i).sprite = NewSprite(enemyTexture)
					SprSetSize(48, 48)
					SprSetAngle(rnd() % 360)
					SprSetSpin(5)
					do 
						SprSetPos(rnd() % int(SpriteAreaWidth()), rnd() % int(SpriteAreaHeight()))
					loop while Length(SprPos() - SprPos(player.sprite)) < 100

					' Mark as active
					enemies(i).isActive = true
					
					' Exit loop
					i = MAXENEMIES
				endif
			next		
		next	

		' Schedule next wave
		nextWaveTimer# = waveDelay#

		' Make next wave harder!
		waveSize = waveSize + 2
		waveDelay# = waveDelay# - 1
		if waveDelay# < 3 then waveDelay# = 3 endif

	else

		' Warn when next wave nearly here
		if nextWaveTimer# < 5 then
			locate (TextCols() - 14) / 2, TextRows() / 2
			print "Next wave in "; int(nextWaveTimer#) + 1;
		endif
	endif

	' Move enemies
	for i = 1 to MAXENEMIES
		if enemies(i).isActive then
			BindSprite(enemies(i).sprite)

			' Accelerate towards player
			SprSetVel(SprVel() + Normalize(SprPos(player.sprite) - SprPos()) * 0.1)
			if Length(SprVel()) > 5 then SprSetVel(Normalize(SprVel()) * 5) endif
		endif
	next

	' Enemy/bullet collisions
	for i = 1 to MAXENEMIES
		if enemies(i).isActive then
			for j = 1 to MAXBULLETS
				if bullets(j).isActive then
					if Length(SprPos(enemies(i).sprite) - SprPos(bullets(j).sprite)) < 16 then

						PlaySound(explosionSound)

						' Remove enemy
						DeleteSprite(enemies(i).sprite)
						enemies(i).isActive = false

						' Remove bullet
						DeleteSprite(bullets(j).sprite)
						bullets(j).isActive = false
				
						' Break out of bullet loop
						j = MAXBULLETS
					endif
				endif
			next
		endif
	next

	locate 0, 0
	print "Sprites: "; SpriteCount()

	AnimateSprites()
	UIEnd()
wend

Run the game and wait a few seconds. Enemies will appear and fly towards you. You'll notice you can shoot them, but they can't hurt you (yet).

There's quite a bit of new code this time, but the commands and techniques should seem familiar.

Once again we've defined a Type TEnemy to define the data that will be stored for each enemy. Again it is a sprite to store the sprite handle and an isActive flag.

We've declared a MAXENEMIES constant to specify the size of the enemies array, and an array with dim enemies(MAXENEMIES) as TEnemy.

We've also declared some new working variables:

nextWaveTimer# holds the number of seconds until the next wave arrives. waveDelay# stores the number of seconds between waves. waveSize stores the number of enemies to generate in each wave.

Inside the main loop is the enemy creation code.

nextWaveTimer# is decreased by (20.0 / 1000.0) seconds (because the loop executes every 20ms, due to the WaitTimer(20) at the top). Once it reaches 0 the next wave is spawned.

Each enemy is generated much like generating a bullet. The array is searched for an entry with isActive set to false, then we create and configure a sprite and set isActive to true. The rnd() function generates a random number between 0 and a large number, and the modulo operator % converts this to a number within our desired range [0-360) for the sprite angle and [(0,0)-(SpriteAreaWidth(), SpriteAreaHeight())) for the position.

Notice the SprSetSpin command. A sprite's spin is is added to the sprite's angle every time AnimateSprites() is executed, much like its velocity is added to its position.

After the wave has been generated we reset nextWaveTimer# to schedule the next wave, and adjust waveSize and waveDelay# to make the game get progressively harder.

To warn the player, we print a message when there are less than 5 seconds to go, using the standard BASIC locate and print commands.

The enemy movement code involves accelerating each enemy towards the player by updating their velocity based on the relative direction of the player.

The final section detects collisions between the enemies and the player's bullets by looping through each and comparing their positions. If the Length of the difference of the SprPos of the bullet and enemy sprites is less than 16, then the two sprites are less than 16 units apart, which we treat as a collision and handle by deleting the sprites of the bullet and enemy, and setting the isActive flag of each to false.

Player collisions and lives

To round out the gameplay, we need to implement player collisions with enemies.

When the player collides with an enemy, they will "die", which means they will be removed from the game for a few seconds, and then respawn. To prevent the player respawning on top of an enemy and immediately dying again (which would be unfair), there will be a short invicibility period. The player will be limited to 3 lives, and the game will end once all are lost.

Here's the updated program:

include standard/gameinput.inc

' Types
type TPlayer
	sprite
	reloadCounter
	isActive
	lives
	respawnTimer#
	invulnerabilityTimer#
end type

type TBullet
	sprite
	isActive
end type

type TEnemy
	sprite
	isActive
end type

' Constants
const MAXBULLETS = 100
const MAXENEMIES = 50
const FIRERATE = 5

' Game data
dim player as TPlayer
dim bullets(MAXBULLETS) as TBullet
dim enemies(MAXENEMIES) as TEnemy

' Working variables
dim gameInput as TTwinStickInput
dim i, j
dim nextWaveTimer# = 6, waveDelay# = 10
dim waveSize = 7

' Textures
dim shipTexture = LoadTex("gfx/F117.png")
dim bulletTexture = LoadTex("gfx/FB01.png")
dim enemyTexture = LoadTex("gfx/TripodFoot.png")

' Sound effects
dim shootSound = LoadSound("sounds/laser.wav")
dim explosionSound = LoadSound("sounds/explos.wav")

' Setup screen
TextMode(TEXT_BUFFERED)

' Setup game
player.sprite = NewSprite(shipTexture)
SprSetSize(32, 32)
SprSetPos(SpriteAreaWidth() / 2, SpriteAreaHeight() / 2)
player.isActive = true
player.lives = 3

' Main loop
while true
	WaitTimer(20)
	UIBegin()

	' Get input
	gameInput = GetTwinStickInput(SprPos(player.sprite))

	' Move player
	if player.isActive then
		BindSprite(player.sprite)
		SprSetVel(gameInput.dir# * 5)
		if gameInput.isAiming then
			SprSetAngle(gameInput.aimAng#)
		endif
		if SprX() < 0 then SprSetX(0) endif
		if SprX() > SpriteAreaWidth() then SprSetX(SpriteAreaWidth()) endif
		if SprY() < 0 then SprSetY(0) endif
		if SprY() > SpriteAreaHeight() then SprSetY(SpriteAreaHeight()) endif

		if player.invulnerabilityTimer# > 0 then
			SprSetVisible((int(player.invulnerabilityTimer# / 0.25) % 2) = 0)
			player.invulnerabilityTimer# = player.invulnerabilityTimer# - (20.0 / 1000.0)
		else
			SprSetVisible(true)
		endif

		' Fire bullets
		if player.reloadCounter > 0 then
			player.reloadCounter = player.reloadCounter - 1
		endif
		if player.reloadCounter = 0 and gameInput.isShooting then
			for i = 1 to MAXBULLETS
				if not bullets(i).isActive then
					PlaySound(shootSound)

					' Create sprite
					bullets(i).sprite = NewSprite(bulletTexture)
					SprSetPos(SprPos(player.sprite))
					SprSetSize(12, 12)
					SprSetVel(gameInput.aim# * 15)

					bullets(i).isActive = true

					' Set reload counter
					player.reloadCounter = FIRERATE
	
					' Exit loop
					i = MAXBULLETS
				endif
			next
		endif
	else

		' Count down to respawn
		player.respawnTimer# = player.respawnTimer# - (20.0 / 1000.0)
		if player.respawnTimer# <= 0 then
			
			if player.lives = 0 then
				locate TextCols() / 2 - 5, TextRows() / 2
				print "GAME OVER!";
				DrawText()
				Sleep(5000)
				run
			endif

			' Respawn player
			BindSprite(player.sprite)	
			SprSetPos(SpriteAreaWidth() / 2, SpriteAreaHeight() / 2)
			player.isActive = true
			player.invulnerabilityTimer# = 3
		endif	
	endif

	' Update bullets
	for i = 1 to MAXBULLETS
		if bullets(i).isActive then
			BindSprite(bullets(i).sprite)			
			if SprBottom() < 0 or SprTop() > SpriteAreaHeight() or SprRight() < 0 or SprLeft() > SpriteAreaWidth() then
				DeleteSprite(bullets(i).sprite)
				bullets(i).isActive = false
			endif
		endif
	next

	' Make enemies	
	nextWaveTimer# = nextWaveTimer# - (20.0 / 1000.0)			' 20 ms per game frame
	if nextWaveTimer# <= 0 then

		' Make enemies
		for j = 1 to waveSize
			for i = 1 to MAXENEMIES
				if not enemies(i).isActive then

					' Spawn enemy
					enemies(i).sprite = NewSprite(enemyTexture)
					SprSetSize(48, 48)
					SprSetAngle(rnd() % 360)
					SprSetSpin(5)
					do 
						SprSetPos(rnd() % int(SpriteAreaWidth()), rnd() % int(SpriteAreaHeight()))
					loop while Length(SprPos() - SprPos(player.sprite)) < 100

					' Mark as active
					enemies(i).isActive = true
					
					' Exit loop
					i = MAXENEMIES
				endif
			next		
		next	

		' Schedule next wave
		nextWaveTimer# = waveDelay#

		' Make next wave harder!
		waveSize = waveSize + 2
		waveDelay# = waveDelay# - 1
		if waveDelay# < 3 then waveDelay# = 3 endif

	else

		' Warn when next wave nearly here
		if nextWaveTimer# < 5 then
			locate (TextCols() - 14) / 2, TextRows() / 2
			print "Next wave in "; int(nextWaveTimer#) + 1;
		endif
	endif

	' Move enemies
	for i = 1 to MAXENEMIES
		if enemies(i).isActive then
			BindSprite(enemies(i).sprite)

			' Accelerate towards player
			SprSetVel(SprVel() + Normalize(SprPos(player.sprite) - SprPos()) * 0.1)
			if Length(SprVel()) > 5 then SprSetVel(Normalize(SprVel()) * 5) endif
		endif
	next

	' Enemy/bullet collisions
	for i = 1 to MAXENEMIES
		if enemies(i).isActive then
			for j = 1 to MAXBULLETS
				if bullets(j).isActive then
					if Length(SprPos(enemies(i).sprite) - SprPos(bullets(j).sprite)) < 16 then

						PlaySound(explosionSound)

						' Remove enemy
						DeleteSprite(enemies(i).sprite)
						enemies(i).isActive = false

						' Remove bullet
						DeleteSprite(bullets(j).sprite)
						bullets(j).isActive = false
				
						' Break out of bullet loop
						j = MAXBULLETS
					endif
				endif
			next
		endif
	next

	' Enemy/player collisions
	if player.isActive and player.invulnerabilityTimer# <= 0 then
		for i = 1 to MAXENEMIES
			if enemies(i).isActive then
				if Length(SprPos(enemies(i).sprite) - SprPos(player.sprite)) < 20 then
					
					PlaySound(explosionSound)
					
					' Remove enemy
					DeleteSprite(enemies(i).sprite)
					enemies(i).isActive = false

					' Hide player
					BindSprite(player.sprite)
					SprSetVisible(false)
					player.isActive = false
					player.respawnTimer# = 3
					player.lives = player.lives - 1	

					' Exit loop
					i = MAXENEMIES
				endif
			endif
		next
	endif

	locate 0, 0
	print "Lives = "; player.lives

	AnimateSprites()
	UIEnd()
wend

Play the game and notice how the player sprite is removed from the screen when you collide with an enemy. Staying alive is now progressively more challenging and the game will eventually end.

We've added some new fields to the TPlayer type to track when the player isActive, the number of lives and a couple of timers for managing the respawn and invulnerability periods. We initialised isActive (true) and lives (3) before the game starts.

In the main loop we've wrapped the player movement and shooting logic in an if player.isActive then conditional statement so that it only executes while the player is on the screen.

You may wonder why the GetTwinStickInput call is outside the if condition. If the player is not moving or shooting why do we need to read the input? The reason is that the twin stick input is implemented as an "immediate mode GUI" which means it must be called every time or the GUI controls will not be displayed. Placing the call inside the if statement would have made the crosshair/joysticks disappear when the player is waiting to respawn, which is not the behaviour we want.

Inside the if statement we've added the invulnerabilityTimer# logic. We use an old technique of making the player flash to indicate that they are invulnerable, by calling SprSetVisible based on the value of the timer. int(player.invulnerabilityTimer# / 0.25) evaluates to an integer that counts down 4 times a second. By taking the remainder when divided by 2 (% 2) we get a number that oscillates between 0 and 1 (4 times a second) which we can pass to SprSetVisible to make the player flash. Once the invulnerabilityTimer# reaches 0 we explicitly set the sprite to visible, just to be sure.

We also have an else section for when the player is not active, to manage the respawnTimer#. When it reaches 0 we check if the player has any lives left. If not we end the game by displaying "GAME OVER!" for 5 seconds then restarting the program with the run command.

If the player still has lives left we re-activate the player, move the player sprite back to the middle of the screen and set the invulnerabilityTimer# to 3 seconds.

The last part is to implement the actual collisions between the player and enemies. This is done much like the collisions between the player's bullets and enemies, by looping through enemies array to find the active ones (isActive is true) and comparing the distance between their sprite's position and the player's sprite's position. When it's less than 20 units we have a collision, which we handle by removing the enemy and hiding the player's sprite, as well as setting the respawnTimer# and decreasing the player's lives.

To keep the player informed, we display their lives at the top left of the screen using traditional locate and print text commands.

Adding some polish

Now that the gameplay is in place, let's work on the presentation.

We're going to add:

Change the code as follows:

include standard/gameinput.inc

' Types
type TPlayer
	sprite
	reloadCounter
	isActive
	lives
	respawnTimer#
	invulnerabilityTimer#
end type

type TBullet
	sprite
	isActive
end type

type TEnemy
	sprite
	isActive
end type

type TParticle
	sprite
	isActive
end type

' Constants
const MAXBULLETS = 100
const MAXENEMIES = 50
const MAXPARTICLES = 500
const FIRERATE = 5

' Game data
dim player as TPlayer
dim bullets(MAXBULLETS) as TBullet
dim enemies(MAXENEMIES) as TEnemy
dim particles(MAXPARTICLES) as TParticle

' Working variables
dim gameInput as TTwinStickInput
dim i, j, k
dim nextWaveTimer# = 6, waveDelay# = 10
dim waveSize = 7

' Textures
dim shipTexture = LoadTex("gfx/F117.png")
dim bulletTexture = LoadTex("gfx/FB01.png")
dim enemyTexture = LoadTex("gfx/TripodFoot.png")
dim explodeTextures(TexStripFrames("gfx/Explode.png")-1) = LoadTexStrip("gfx/Explode.png")
SetTexTransparentCol(0,0,0)
dim spaceTextures(TexStripFrames("gfx/spaceTiles.png")-1) = LoadTexStrip("gfx/spaceTiles.png")

' Sound effects
dim shootSound = LoadSound("sounds/laser.wav")
dim explosionSound = LoadSound("sounds/explos.wav")

' Setup screen
TextMode(TEXT_BUFFERED)
BackgroundColor(15, 0, 10)

' Setup tile map
dim tileMap, tileIndices(9)(9)
tileMap = NewTileMap(spaceTextures)  
data 2,2,3,1,2,1,1,3,2,3
data 3,3,2,3,2,3,2,3,2,2
data 3,1,2,3,3,4,3,2,3,3
data 2,1,4,2,5,3,3,1,2,1
data 2,3,2,3,3,1,2,3,3,3
data 2,2,2,1,2,2,3,1,3,2
data 3,1,3,1,2,2,2,2,1,3
data 2,1,2,4,1,3,2,3,2,3
data 2,2,3,2,3,3,2,3,3,1
data 3,2,1,2,3,2,1,2,2,1
for i = 0 to 9
	for j = 0 to 9
		read tileIndices(i)(j)
	next
next
SprSetSolid(false)
SprSetTiles(tileIndices)
SprSetZOrder(100)      ' Push tile map to back
SprSetScale(2)
SprSetXRepeat(true)
SprSetYRepeat(true)
SprSetVel(0.6, 0.4)

' Setup game
player.sprite = NewSprite(shipTexture)
SprSetSize(32, 32)
SprSetPos(SpriteAreaWidth() / 2, SpriteAreaHeight() / 2)
player.isActive = true
player.lives = 3

' Main loop
while true
	WaitTimer(20)
	UIBegin()

	' Get input
	gameInput = GetTwinStickInput(SprPos(player.sprite))

	' Move player
	if player.isActive then
		BindSprite(player.sprite)
		SprSetVel(gameInput.dir# * 5)
		if gameInput.isAiming then
			SprSetAngle(gameInput.aimAng#)
		endif
		if SprX() < 0 then SprSetX(0) endif
		if SprX() > SpriteAreaWidth() then SprSetX(SpriteAreaWidth()) endif
		if SprY() < 0 then SprSetY(0) endif
		if SprY() > SpriteAreaHeight() then SprSetY(SpriteAreaHeight()) endif

		if player.invulnerabilityTimer# > 0 then
			SprSetVisible((int(player.invulnerabilityTimer# / 0.25) % 2) = 0)
			player.invulnerabilityTimer# = player.invulnerabilityTimer# - (20.0 / 1000.0)
		else
			SprSetVisible(true)
		endif

		' Fire bullets
		if player.reloadCounter > 0 then
			player.reloadCounter = player.reloadCounter - 1
		endif
		if player.reloadCounter = 0 and gameInput.isShooting then
			for i = 1 to MAXBULLETS
				if not bullets(i).isActive then
					PlaySound(shootSound)

					' Create sprite
					bullets(i).sprite = NewSprite(bulletTexture)
					SprSetPos(SprPos(player.sprite))
					SprSetSize(12, 12)
					SprSetVel(gameInput.aim# * 15)

					bullets(i).isActive = true

					' Set reload counter
					player.reloadCounter = FIRERATE
	
					' Exit loop
					i = MAXBULLETS
				endif
			next
		endif
	else

		' Count down to respawn
		player.respawnTimer# = player.respawnTimer# - (20.0 / 1000.0)
		if player.respawnTimer# <= 0 then
			
			if player.lives = 0 then
				locate TextCols() / 2 - 5, TextRows() / 2
				print "GAME OVER!";
				DrawText()
				Sleep(5000)
				run
			endif

			' Respawn player
			BindSprite(player.sprite)	
			SprSetPos(SpriteAreaWidth() / 2, SpriteAreaHeight() / 2)
			player.isActive = true
			player.invulnerabilityTimer# = 3
		endif	
	endif

	' Update bullets
	for i = 1 to MAXBULLETS
		if bullets(i).isActive then
			BindSprite(bullets(i).sprite)			
			if SprBottom() < 0 or SprTop() > SpriteAreaHeight() or SprRight() < 0 or SprLeft() > SpriteAreaWidth() then
				DeleteSprite(bullets(i).sprite)
				bullets(i).isActive = false
			endif
		endif
	next

	' Make enemies	
	nextWaveTimer# = nextWaveTimer# - (20.0 / 1000.0)			' 20 ms per game frame
	if nextWaveTimer# <= 0 then

		' Make enemies
		for j = 1 to waveSize
			for i = 1 to MAXENEMIES
				if not enemies(i).isActive then

					' Spawn enemy
					enemies(i).sprite = NewSprite(enemyTexture)
					SprSetSize(48, 48)
					SprSetAngle(rnd() % 360)
					SprSetSpin(5)
					SprSetScale(0.1)
					do 
						SprSetPos(rnd() % int(SpriteAreaWidth()), rnd() % int(SpriteAreaHeight()))
					loop while Length(SprPos() - SprPos(player.sprite)) < 100

					' Mark as active
					enemies(i).isActive = true
					
					' Exit loop
					i = MAXENEMIES
				endif
			next		
		next	

		' Schedule next wave
		nextWaveTimer# = waveDelay#

		' Make next wave harder!
		waveSize = waveSize + 2
		waveDelay# = waveDelay# - 1
		if waveDelay# < 3 then waveDelay# = 3 endif

	else

		' Warn when next wave nearly here
		if nextWaveTimer# < 5 then
			locate (TextCols() - 14) / 2, TextRows() / 2
			print "Next wave in "; int(nextWaveTimer#) + 1;
		endif
	endif

	' Move enemies
	for i = 1 to MAXENEMIES
		if enemies(i).isActive then
			BindSprite(enemies(i).sprite)

			' Accelerate towards player
			SprSetVel(SprVel() + Normalize(SprPos(player.sprite) - SprPos()) * 0.1)
			if Length(SprVel()) > 5 then SprSetVel(Normalize(SprVel()) * 5) endif
			if SprScale() < 1.0 then SprSetScale(SprScale() + 0.05) endif
		endif
	next

	' Update particles
	for i = 1 to MAXPARTICLES
		if particles(i).isActive then
			if SprAnimDone(particles(i).sprite) then
				DeleteSprite(particles(i).sprite)
				particles(i).isActive = false
			endif
		endif
	next	

	' Enemy/bullet collisions
	for i = 1 to MAXENEMIES
		if enemies(i).isActive then
			for j = 1 to MAXBULLETS
				if bullets(j).isActive then
					if Length(SprPos(enemies(i).sprite) - SprPos(bullets(j).sprite)) < 16 then

						PlaySound(explosionSound)

						' Create particle
						for k = 1 to MAXPARTICLES
							if not particles(k).isActive then
								particles(k).sprite = NewSprite(explodeTextures)
								SprSetPos(SprPos(enemies(i).sprite))
								SprSetSize(100, 100)
								SprSetAnimSpeed(0.6)
								SprSetAnimLoop(false)
								particles(k).isActive = true
								k = MAXPARTICLES
							endif
						next

						' Remove enemy
						DeleteSprite(enemies(i).sprite)
						enemies(i).isActive = false

						' Remove bullet
						DeleteSprite(bullets(j).sprite)
						bullets(j).isActive = false
				
						' Break out of bullet loop
						j = MAXBULLETS
					endif
				endif
			next
		endif
	next

	' Enemy/player collisions
	if player.isActive and player.invulnerabilityTimer# <= 0 then
		for i = 1 to MAXENEMIES
			if enemies(i).isActive then
				if Length(SprPos(enemies(i).sprite) - SprPos(player.sprite)) < 20 then
					
					PlaySound(explosionSound)

					' Create particle
					for k = 1 to MAXPARTICLES
						if not particles(k).isActive then
							particles(k).sprite = NewSprite(explodeTextures)
							SprSetPos(SprPos(enemies(i).sprite))
							SprSetSize(150, 150)
							SprSetAnimSpeed(0.4)
							SprSetAnimLoop(false)
							particles(k).isActive = true
							k = MAXPARTICLES
						endif
					next
					
					' Remove enemy
					DeleteSprite(enemies(i).sprite)
					enemies(i).isActive = false

					' Hide player
					BindSprite(player.sprite)
					SprSetVisible(false)
					player.isActive = false
					player.respawnTimer# = 3
					player.lives = player.lives - 1	

					' Exit loop
					i = MAXENEMIES
				endif
			endif
		next
	endif

	locate 0, 0
	print "Lives = "; player.lives

	AnimateSprites()
	UIEnd()
wend

A bit of visual polish makes the game a lot more satisfying.

The techniques used to introduce and manage our new particle type should be familiar by now. In our "particle engine" a particle is an element that doesn't influence the gameplay. It has an animated sprite and logic to remove it from the screen when the animation has finished playing.

Let's break down the changes:

We've created a TParticle type with a sprite handle and isActive flag, and used it to declare an array of particles.

We've loaded the "gfx/Explode.png" image into an array of textures using LoadTexStrip. This function loads an image and splits it up into frames. Because we didn't specify the frame size it assumes the image is a horizontal or vertical strip of square frames and splits it up accordingly. In this case our image is a horizontal strip of square images, each one being an animation frame of an explosion, so we end up with an array of textures of the different animation frames.

The explodeTextures array variable holds the resulting array of textures. We have to dim it with the right amount of elements, so we use the TexStripFrames function to get the number required, then subtract 1, because dim always allocates one more element than you specify (it allocates space for indices from 0 up to and including the specified count).

The "gfx/spaceTiles.png" image is loaded the same way, although it is not an animation but rather a set of tiled frames to use in a tile map. We want the tiles to be transparent, but the image does not contain an alpha channel, so we instruct the image loader to treat black as a transparent colour using the SetTexTransparentCol command. This tells the texture loading routines to create a transparent texture, replacing the black pixels with transparent ones, which is why it must be executed before LoadTexStrip.

BackgroundColor(15, 0, 10) sets the background colour to a dark purple (the parameters are the red, green and blue intensities out of 255).

Then we create the tile map, using code which was pinched from the Basic4GL mobile asteroid demo! A "tile map" is a special kind of sprite that is built out of multiple tiled images. To create a tile map you must pass it an array of textures ( NewTileMap(spaceTextures)) and then another 2D array containing the indices to use for each tile (SprSetTiles(tileIndices)). We've used a bunch of new sprite commands to make the tile map transparent (SprSetSolid(false)), push it to the back SprSetZOrder(100) and make it repeat infinitely horizontally ( SprSetXRepeat(true) and vertically (SprSetYRepeat(true). Finally we give it a small amount of velocity with SprSetVel(0.6, 0.4). The tile map sprite will happily scroll away in the background behind the action.

Inside the main loop we've added animation logic to spawning enemies. SprSetScale(0.1) makes them start at 10% of their normal size. Then SprSetScale(SprScale() + 0.05) grows them up to full size.

We've added a simple loop to detect when particles have finished playing their animation via the SprAnimDone function, and remove them from the screen.

When a bullet collides with an enemy we allocate a new particle, and create an animated explosions sprite using the explosion texture with NewSprite(explodeTextures). SprSetAnimSpeed(0.6) sets the animation speed to 0.6 frames every AnimateSprites call. SprSetAnimLoop(false) specifies that the animation will play once and stop. Otherwise it will loop around continuously, which is not appropriate for our explosion animation.

We also create a larger, slower animating particle when the player collides with an enemy, to represent the exploding player, using very similar code.

For simplicity the create-particle code has been duplicated in this tutorial. In your own programs you might want to consider using a BASIC subroutine instead, which would avoid code duplication and having to allocate global "working" variables like i, j and k. In fact much of this tutorial's code would benefit from being split up into subroutines and functions or even separate include files.

Structuring your program this way makes it easier to manage, especially when it starts to grow in size and complexity.

See the Basic4GL Mobile "Language Guide" helpfile for more information on subroutines and functions.

Adding a UI

To finish off we'll add a high score table and some in game-menus.

The high score table will be persisted to file and displayed after the game finishes, as well as in the pause menu, which the player will access by pressing a "Pause" button at the top of the screen.

We'll also need to keep track of the player's score, and prompt them for their initials if they make it to the high score table.

We'll use the immediate mode GUI (imgui) routines to create on-screen buttons, text boxes and sliders.

Here's the updated code:

include standard/imspriteui.inc
include standard/gameinput.inc

' Types
type TPlayer
	sprite
	reloadCounter
	isActive
	lives
	respawnTimer#
	invulnerabilityTimer#
	score
end type

type TBullet
	sprite
	isActive
end type

type TEnemy
	sprite
	isActive
end type

type TParticle
	sprite
	isActive
end type

type THighScore
	initials$
	score
end type

' Constants
const MAXBULLETS = 100
const MAXENEMIES = 50
const MAXPARTICLES = 500
const MAXHIGHSCORES = 9
const BUTTONWIDTH = 160
const BUTTONFULLWIDTH = BUTTONWIDTH + 32
const FIRERATE = 5

' Game data
dim player as TPlayer
dim bullets(MAXBULLETS) as TBullet
dim enemies(MAXENEMIES) as TEnemy
dim particles(MAXPARTICLES) as TParticle
dim highScores(MAXHIGHSCORES) as THighScore

' Settings
dim volume# = 1.0

' Working variables
dim gameInput as TTwinStickInput
dim i, j, k, file, inits$
dim nextWaveTimer# = 6, waveDelay# = 10
dim waveSize = 7

' Textures
dim shipTexture = LoadTex("gfx/F117.png")
dim bulletTexture = LoadTex("gfx/FB01.png")
dim enemyTexture = LoadTex("gfx/TripodFoot.png")
dim explodeTextures(TexStripFrames("gfx/Explode.png")-1) = LoadTexStrip("gfx/Explode.png")
SetTexTransparentCol(0,0,0)
dim spaceTextures(TexStripFrames("gfx/spaceTiles.png")-1) = LoadTexStrip("gfx/spaceTiles.png")

' Sound effects
dim shootSound = LoadSound("sounds/laser.wav")
dim explosionSound = LoadSound("sounds/explos.wav")

' Setup screen
TextMode(TEXT_BUFFERED)
BackgroundColor(15, 0, 10)
ResizeText(SpriteAreaWidth()/16, SpriteAreaHeight()/16)

' Setup UI
UIButtonResize(uiButtonDef, BUTTONWIDTH)
uiTextboxDef.xSize = 64
uiTextboxDef.maxLength = 3

' Setup tile map
dim tileMap, tileIndices(9)(9)
tileMap = NewTileMap(spaceTextures)  
data 2,2,3,1,2,1,1,3,2,3
data 3,3,2,3,2,3,2,3,2,2
data 3,1,2,3,3,4,3,2,3,3
data 2,1,4,2,5,3,3,1,2,1
data 2,3,2,3,3,1,2,3,3,3
data 2,2,2,1,2,2,3,1,3,2
data 3,1,3,1,2,2,2,2,1,3
data 2,1,2,4,1,3,2,3,2,3
data 2,2,3,2,3,3,2,3,3,1
data 3,2,1,2,3,2,1,2,2,1
for i = 0 to 9
	for j = 0 to 9
		read tileIndices(i)(j)
	next
next
SprSetSolid(false)
SprSetTiles(tileIndices)
SprSetZOrder(100)      ' Push tile map to back
SprSetScale(2)
SprSetXRepeat(true)
SprSetYRepeat(true)
SprSetVel(0.6, 0.4)

' Setup game
player.sprite = NewSprite(shipTexture)
SprSetSize(32, 32)
SprSetPos(SpriteAreaWidth() / 2, SpriteAreaHeight() / 2)
player.isActive = true
player.lives = 3

' Default high scores
for i = 1 to MAXHIGHSCORES
	highScores(i).initials$ = "TOM"
	highScores(i).score = (MAXHIGHSCORES - i + 1) * 200
next

' Load high scores
file = OpenFileRead("scores.txt")
if FileError() = "" then
	for i = 1 to MAXHIGHSCORES
		if not EndOfFile(file) then
			highScores(i).initials$ = ReadLine(file)
			highScores(i).score = val(ReadLine(file))
		endif
	next
endif
CloseFile(file)

' Main loop
while true
	WaitTimer(20)
	UIBegin()

	' Get input
	gameInput = GetTwinStickInput(SprPos(player.sprite))

	' Move player
	if player.isActive then
		BindSprite(player.sprite)
		SprSetVel(gameInput.dir# * 5)
		if gameInput.isAiming then
			SprSetAngle(gameInput.aimAng#)
		endif
		if SprX() < 0 then SprSetX(0) endif
		if SprX() > SpriteAreaWidth() then SprSetX(SpriteAreaWidth()) endif
		if SprY() < 0 then SprSetY(0) endif
		if SprY() > SpriteAreaHeight() then SprSetY(SpriteAreaHeight()) endif

		if player.invulnerabilityTimer# > 0 then
			SprSetVisible((int(player.invulnerabilityTimer# / 0.25) % 2) = 0)
			player.invulnerabilityTimer# = player.invulnerabilityTimer# - (20.0 / 1000.0)
		else
			SprSetVisible(true)
		endif

		' Fire bullets
		if player.reloadCounter > 0 then
			player.reloadCounter = player.reloadCounter - 1
		endif
		if player.reloadCounter = 0 and gameInput.isShooting then
			for i = 1 to MAXBULLETS
				if not bullets(i).isActive then
					PlaySound(shootSound, volume# * 0.5, false)

					' Create sprite
					bullets(i).sprite = NewSprite(bulletTexture)
					SprSetPos(SprPos(player.sprite))
					SprSetSize(12, 12)
					SprSetVel(gameInput.aim# * 15)

					bullets(i).isActive = true

					' Set reload counter
					player.reloadCounter = FIRERATE
	
					' Exit loop
					i = MAXBULLETS
				endif
			next
		endif
	else

		' Count down to respawn
		player.respawnTimer# = player.respawnTimer# - (20.0 / 1000.0)
		if player.respawnTimer# <= 0 then
			
			if player.lives = 0 then
				gosub GameOver
				run
			endif

			' Respawn player
			BindSprite(player.sprite)	
			SprSetPos(SpriteAreaWidth() / 2, SpriteAreaHeight() / 2)
			player.isActive = true
			player.invulnerabilityTimer# = 3
		endif	
	endif

	' Update bullets
	for i = 1 to MAXBULLETS
		if bullets(i).isActive then
			BindSprite(bullets(i).sprite)			
			if SprBottom() < 0 or SprTop() > SpriteAreaHeight() or SprRight() < 0 or SprLeft() > SpriteAreaWidth() then
				DeleteSprite(bullets(i).sprite)
				bullets(i).isActive = false
			endif
		endif
	next

	' Make enemies	
	nextWaveTimer# = nextWaveTimer# - (20.0 / 1000.0)			' 20 ms per game frame
	if nextWaveTimer# <= 0 then

		' Make enemies
		for j = 1 to waveSize
			for i = 1 to MAXENEMIES
				if not enemies(i).isActive then

					' Spawn enemy
					enemies(i).sprite = NewSprite(enemyTexture)
					SprSetSize(48, 48)
					SprSetAngle(rnd() % 360)
					SprSetSpin(5)
					SprSetScale(0.1)
					do 
						SprSetPos(rnd() % int(SpriteAreaWidth()), rnd() % int(SpriteAreaHeight()))
					loop while Length(SprPos() - SprPos(player.sprite)) < 100

					' Mark as active
					enemies(i).isActive = true
					
					' Exit loop
					i = MAXENEMIES
				endif
			next		
		next	

		' Schedule next wave
		nextWaveTimer# = waveDelay#

		' Make next wave harder!
		waveSize = waveSize + 2
		waveDelay# = waveDelay# - 1
		if waveDelay# < 3 then waveDelay# = 3 endif

	else

		' Warn when next wave nearly here
		if nextWaveTimer# < 5 then
			locate (TextCols() - 14) / 2, TextRows() / 2
			print "Next wave in "; int(nextWaveTimer#) + 1;
		endif
	endif

	' Move enemies
	for i = 1 to MAXENEMIES
		if enemies(i).isActive then
			BindSprite(enemies(i).sprite)

			' Accelerate towards player
			SprSetVel(SprVel() + Normalize(SprPos(player.sprite) - SprPos()) * 0.1)
			if Length(SprVel()) > 5 then SprSetVel(Normalize(SprVel()) * 5) endif
			if SprScale() < 1.0 then SprSetScale(SprScale() + 0.05) endif
		endif
	next

	' Update particles
	for i = 1 to MAXPARTICLES
		if particles(i).isActive then
			if SprAnimDone(particles(i).sprite) then
				DeleteSprite(particles(i).sprite)
				particles(i).isActive = false
			endif
		endif
	next	

	' Enemy/bullet collisions
	for i = 1 to MAXENEMIES
		if enemies(i).isActive then
			for j = 1 to MAXBULLETS
				if bullets(j).isActive then
					if Length(SprPos(enemies(i).sprite) - SprPos(bullets(j).sprite)) < 16 then

						player.score = player.score + 10
						PlaySound(explosionSound, volume# * 0.8, false)

						' Create particle
						for k = 1 to MAXPARTICLES
							if not particles(k).isActive then
								particles(k).sprite = NewSprite(explodeTextures)
								SprSetPos(SprPos(enemies(i).sprite))
								SprSetSize(100, 100)
								SprSetAnimSpeed(0.6)
								SprSetAnimLoop(false)
								particles(k).isActive = true
								k = MAXPARTICLES
							endif
						next

						' Remove enemy
						DeleteSprite(enemies(i).sprite)
						enemies(i).isActive = false

						' Remove bullet
						DeleteSprite(bullets(j).sprite)
						bullets(j).isActive = false
				
						' Break out of bullet loop
						j = MAXBULLETS
					endif
				endif
			next
		endif
	next

	' Enemy/player collisions
	if player.isActive and player.invulnerabilityTimer# <= 0 then
		for i = 1 to MAXENEMIES
			if enemies(i).isActive then
				if Length(SprPos(enemies(i).sprite) - SprPos(player.sprite)) < 20 then
					
					PlaySound(explosionSound, volume# * 1.0, false)

					' Create particle
					for k = 1 to MAXPARTICLES
						if not particles(k).isActive then
							particles(k).sprite = NewSprite(explodeTextures)
							SprSetPos(SprPos(enemies(i).sprite))
							SprSetSize(150, 150)
							SprSetAnimSpeed(0.4)
							SprSetAnimLoop(false)
							particles(k).isActive = true
							k = MAXPARTICLES
						endif
					next
					
					' Remove enemy
					DeleteSprite(enemies(i).sprite)
					enemies(i).isActive = false

					' Hide player
					BindSprite(player.sprite)
					SprSetVisible(false)
					player.isActive = false
					player.respawnTimer# = 3
					player.lives = player.lives - 1	

					' Exit loop
					i = MAXENEMIES
				endif
			endif
		next
	endif

	locate 0, 0
	print "Score = "; player.score
	print "Lives = "; player.lives
	
	UILocate(SpriteAreaWidth() - BUTTONFULLWIDTH, 24)
	if UIButton("Pause") then gosub PauseMenu endif

	AnimateSprites()
	UIEnd()
wend

PauseMenu:
	while true
		UIBegin()
		
		UILocate(SpriteAreaWidth() - BUTTONFULLWIDTH, 24)
		if UIButton("Resume") then return endif
		gosub HighScoreTable

		' Volume
		UILabel("Volume")
		UISlider(volume#, 0, 1, 0.1)		

		UIEnd()
		Sleep(20)
	wend

HighScoreTable:
	UILocate(SpriteAreaWidth() / 2 - 128, 16)
	UILayout(true)
	UILabel("Player"): UILabel("Score"): UINewLine()		
	for i = 1 to MAXHIGHSCORES
		UILabel(i + "." + highScores(i).initials$): UILabel(highScores(i).score): UINewLineNoSpace()
	next
	UINewLineNoSpace()
	UILayout(false)
	return

GameOver:
	' Show Game over
	locate TextCols() / 2 - 5, TextRows() / 2
	print "GAME OVER!";
	DrawText()
	Sleep(5000)

	' Check if player has a high score
	i = MAXHIGHSCORES + 1
	while i > 1 land highScores(i - 1).score < player.score
		i = i - 1
	wend
	
	if i <= MAXHIGHSCORES then
		gosub GetInitials
		if inits$ <> "" then

			' Insert new high score
			for j = MAXHIGHSCORES to i + 1 step -1
				highScores(j) = highScores(j - 1)
			next
			highScores(i).initials$ = inits$
			highScores(i).score = player.score

			' Save high scores
			file = OpenFileWrite("scores.txt")
			for i = 1 to MAXHIGHSCORES			
				WriteLine(file, highScores(i).initials$)
				WriteLine(file, highScores(i).score)
			next
			CloseFile(file)
		endif
	endif	

	' Show high scores
	while true
		UIBegin()
		gosub HighScoreTable
		if UIButton("Play again") then return endif
		UIEnd()
		Sleep(20)
	wend		

GetInitials:
	inits$ = ""
	while true
		UIBegin()
		UILocate(SpriteAreaWidth() / 2 - 128, 64)
		UILabel("New high score!")
		UILayout(true)
		UILabel("Initials")
		if UITextBox(inits$) or UIButton("Done") then return endif
		UIEnd()
		Sleep(20)
	wend

There's a fair few new lines of code, but it's not too bad for an in game menu.

Complexity alert!

It's worth stopping and considering the complexity at this point. We've probably reached the size and complexity where using global variables and GOSUBs starts to get error prone and difficult to manage. If we were going to take this program further it would make sense to refactor the various steps into SUBroutines or FUNCTIONs.

That's outside of the scope of this tutorial however.

Once again let's analyse the new and changed code.

include standard/imspriteui.inc includes the sprite based immediate-mode-gui library. Technically it's not required, because the "gameinput.inc" library which provides the twin stick input routines also includes it, but it doesn't hurt to be explicit. Basic4GL Mobile recognises that it has already included the file and ignores any subsequent includes.

We've defined a THighScore data type to store players' initials$ and score, a constant MAXHIGHSCORES to specify how many we will store and display, and an array highScores to store them.

The other constants BUTTONWIDTH and BUTTONFULLWIDTH are used to specify the width (in sprite units) of the on screen buttons, as well see soon.

Global variable volume# is added to control the volume level (1.0 = full, 0 = silent), and we've added some more working variables file and inits$ for file access and UI input.

ResizeText specifies the number of columns and rows of text on the screen. The text is scaled to fit accordingly. The immediate mode GUI works best with text characters that are 16x16 sprite units in size otherwise there can be alignment issues.

We also make some changes to the default immediate mode GUI button and text box parameters, by altering the fields of the global variables uiButtonDef and uiTextboxDef, using the UIButtonResize helper function. All these are defined inside the standard/imspriteui.inc library. Essentially we are telling it to make the buttons narrower (the default size is quite wide) and narrow the text boxes right down and restrict them to 3 characters only. We're setting the default for all text boxes, which we can get away with because we only have one text box which we use to get the player's initials. If we were using text boxes for other things we wouldn't want to restrict them all to 3 characters, and instead would declare our own text box parameters variable and use the UITextBoxExt function, which accepts an explicit "textbox parameters" parameter.

The last piece of code before the main loop is to set the default high scores then load them in from file.

' Load high scores
file = OpenFileRead("scores.txt")
if FileError() = "" then
	for i = 1 to MAXHIGHSCORES
		if not EndOfFile(file) then
			highScores(i).initials$ = ReadLine(file)
			highScores(i).score = val(ReadLine(file))
		endif
	next
endif
CloseFile(file)

OpenFileRead("scores.txt") opens file "scores.txt" for reading. On PC it will look in the same folder as the BASIC program file (i.e. the folder containing twinstick.bglm). On Android it looks in the "basic4gl" folder (where you place program.vm and the other assets). The command returns a handle which we save to the file variable to use for reading from the file with the ReadLine command (which reads a line from the file), and finally close the file with CloseFile. Note that we assume any error opening the file means that the file doesn't exist and simply skip it.

Inside the main loop the main changes are:

  1. Changing the PlaySound calls to include a volume factor. E.g. PlaySound(shootSound, volume# * 0.5, file) plays a sound at volume# * 0.5 of maximum volume (so half maximum when volume# is set to full 1.0).

  2. Moving the game-over code to the GameOver label, and calling it with gosub. (More on this later.)

  3. Adding 10 points to the player.score when an enemy is hit by a bullet, and displaying player.score above the player's lives using the standard print command.

  4. Displaying a pause button using the UILocate and UIButton routines from the standard/imspriteui.inc library.

UIButton draws a button on the screen and returns true if the user has pressed it. You must call UIButton every time around the loop in order for the button to continue to exist (which is a defining trait of immediate mode GUIs).

UILocate specifies where the button should be placed, in sprite coordinates. Actually it specifies where the next UI controls should be placed - the GUI maintains a "cursor" which it advances down after each UI control is drawn, so that subsequent UI controls will appear underneath. In this case we place the button at the very top right hand side of the screen and call the pause menu code, via gosub PauseMenu, when the player clicks it.

PauseMenu:
	while true
		UIBegin()
		
		UILocate(SpriteAreaWidth() - BUTTONFULLWIDTH, 24)
		if UIButton("Resume") then return endif
		gosub HighScoreTable

		' Volume
		UILabel("Volume")
		UISlider(volume#, 0, 1, 0.1)		

		UIEnd()
		Sleep(20)
	wend

Not surprisingly PauseMenu: section implements the pause menu. It has it's own display loop, with UIBegin() and UIEnd() calls to drive the immediate mode GUI. The Sleep(20) slows the loop down to 50 times per second - there's no need to waste CPU and GPU by going any faster.

Again we use UILocate and UIButton to display a button at the top right. This time it's a "Resume" button to resume the game, which we do by returning from the loop. gosub HighScoreTable invokes the high score table display code, which we've placed under its own label so that we can share it with the post game code. Underneath we display a slider for the volume. UILabel("Volume") prints the text "Volume". We could have just used locate and print commands, but we would have had to figure out exactly where to place the label and then adjust the slider's position, whereas UILabel does this for us automatically. UISlider(volume#, 0, 1, 0.1) displays the slider, and attaches it to the global variable volume#. The remaining parameters are: minimum value, maximum value and increment. So this lets us slide volume# from 0 to 1 in increments of 0.1.

HighScoreTable:
	UILocate(SpriteAreaWidth() / 2 - 128, 16)
	UILayout(true)
	UILabel("Player"): UILabel("Score"): UINewLine()		
	for i = 1 to MAXHIGHSCORES
		UILabel(i + "." + highScores(i).initials$): UILabel(highScores(i).score): UINewLineNoSpace()
	next
	UILayout(false)
	UINewLineNoSpace()
	return

HighScoreTable: displays the highScores array on screen using UILocate and UILabel. We use UILayout(true) to tell the UI library to lay content out horizontally instead of vertically, so that we can place the score besides the initials$. This means we have to tell it explicitly when to move down to the next line, which is what UINewLine and UINewLineNoSpace do. We set the layout back to vertical with UILocate(false) before returning.

The GameOver: code should look familiar. After displaying "GAME OVER!" it checks whether the player has a high score and where it should go on the high score table. If they have a high score, it prompts them for their initials (via gosub GetInitials) moves the existing scores down to make a gap and writes the player's new score into the correct position. Then it saves the updated high score table to file, using OpenFileWrite and WriteLine. These operate much like OpenFileRead and ReadLine but (unsurprisingly) write to file rather than read from it.

After that it displays the post-game UI, consisting of the high score table and a "Play again" button.

GetInitials: prompts for the player's initials. UITextBox(inits$) displays a text box that the user can type into. The function returns true if the user presses enter, which we use to accept the player's input and return. In case it's not obvious that enter needs to be pressed, we include an on screen "Done" button which they can click instead.

A couple of things to note:

Afterwood

So there we have it. A twin stick shooter with PC and mobile controls in just over 400 lines of code.

I hope this tutorial helped to demonstrate some basic techniques of a 2D game, such as:

One of the takeaways should be how the immediate mode GUI routines make input a lot simpler. For twin stick input we just had to dim a variable and call GetTwinStickInput, then read the velocity and direction input from the result. This covers the PC and mobile input cases even though the actual method of input (on-screen touch controls vs mouse and keyboard) are significantly different.

You should also check out the GetGamepadInput routine (also part of standard/gameinput.inc), which implements directional input and action buttons - a common control scheme for regular 2D shooters, platformers etc. These helper functions make it a lot easier to get games up and running - especially on mobile.

Some suggestions for taking the game further:

Hope you enjoyed this tutorial.