MultiPlayer with GNet for Beginners (BlitzMax)

Started by Midimaster, July 14, 2023, 08:28:32

Previous topic - Next topic

Midimaster

To develop a GNet based app is a little bit tricky, because you would need to have a already perfect running "server"-app to test the code of your "client"-app and vice-versa.

So here I show a way to develope both at the same time in step-by-step mode and expand it from day to day.

In general I can say, that it is not a good idea to combine  immediately the new GNet-code with your existing 3D-game. Better is to develop the code in an independent app, which is first only PRINT-based. In a second step you can expand it to simple 2D-base. And at last, when all GNet-features are working, you integrate the code into your main game.

GNetTPlayer.gif
GNet can work with two running apps on the same computer or with several computers in your LAN, but also with computers spreaded worldwide. One of them (often the computer which started first) is the "server". He has the job of distributing the datas to all the clients. But you do not need to write any code line related to this. You only start one of the computers as "server" and GNet does the rest automatically under-the-hood. So we can say: The "server" is also a normal client with the same code like he other clients. With the only exception: that he was the one that reached the code line with GNetListen() as first.


Lesson I: A Minimum GNet-Code

How many lines do we need to code a minimalistic server/client-app with both running on the same computer?

SuperStrict
Graphics 400,300
Global Host:TGNetHost=CreateGNetHost()
Local Name:String
If GNetListen(Host,12345) = True
    Name="SERVER"
Else
    Name="Client"
    GNetConnect Host,"127.0.0.1",12345
EndIf

Repeat
    GNetSync Host
    DrawText "HERE " + Name, 150,10
    Flip 1
Until AppTerminate()
CloseGNetHost(Host)

This is all we need. Compile this a run it. Allow windows to open the network for us. You will see your app with the title "SERVER". Move the app window a little bit to the right. Then go to the project folder and start the same EXE for a second time. A second app-window will open and this becomes the "CLIENT", because the "SERVER" already exists.

So we learn,
1.
Every app needs a "Host" with CreateGnetHost() to participate in our GNet.
2.
The first app that calls GNetListen() is the server.
3.
All others, who try it afterwards, will automatically become clients.
4.
They connect to the existing network with GNetConnect().
5.
The continous listening happens in the GNetSync() function


...back from Egypt

Midimaster

#1
Lesson II: Now lets add some Players

A Player in the GNet needs a GNetObject to communicate. Also the "server" can be used as a client, when he defines his own GNetObject. So the next step needs only a few code lines:

SuperStrict
Graphics 400,300
Global Host:TGNetHost=CreateGNetHost()
Local Name:String
If GNetListen(Host,12345) = True
    Name="SERVER"
Else
    If GNetConnect(Host,"127.0.0.1",12345)=False
        Name ="Error"
    Else
        Name="Client"
    EndIf
EndIf

Global GObj:TGNetObject = CreateGNetObject(Host)

Repeat
    Cls
    GNetSync(Host)
    DrawText "HERE=" + Name, 150,10
    ScanGNet
    Flip 1
Until AppTerminate()
CloseGNetHost(Host)

Function ScanGnet()
    Local i:Int
    For Local obj:TGNetObject=EachIn GNetObjects( host, GNET_ALL )
        i=i+1
    Next
    DrawText "Members:" + i , 30,100
End Function

We add our function ScanGNet(), that iterates through all avaiable members. GNetObjects() is a function that returns a list of all members. So we can count the members or ask for their properties via this list.

Start this app (again twice, like descriped in Lesson I) and you will see, that the numbers of members will raise with each new started copy of the app.
...back from Egypt

Midimaster

#2
Lesson III: Combination with TPlayer


In games we often have a TYPE TPlayer, where you move and interact your players. Here we store are all informations about look, equipment and actions of the players. It would be very helpful if also the GNetObject is a part (a field) of the TPLAYER.

So here we will combine both:

'Global GObj:TGNetObject = CreateGNetObject(Host)
Global Me:TPlayer = TPlayer.AddMe(Name)
Repeat
  ...
Until....

Type TPlayer
    Global All:TList = New TList
    Field GObj:TGNetObject
   
    Function AddMe:TPlayer(Name:String)
        Local loc:TPlayer = New TPlayer
        loc.GObj = CreateGNetObject(Host)
        All.AddLast loc
        Return loc
    End Function
End Type

In a first step we change the connecting to GNet from "before the main loop" to "inside the TPlayer". This function AddMe() create a new TPlayer, connects to the GNet and stores the GObj in a field of the player. At the end we add the player to the list of All players.


We can receive a message about the joining of other players in our ScanGNet() function. Therefore we ask only for new players by using the CONST GNET_CREATED:

Function ScanGnet()
    ' find new
    For Local obj:TGNetObject=EachIn GNetObjects( host, GNET_CREATED )
        TPlayer.AddOthers obj
    Next
    DrawText "TPlayers:" + TPlayer.All.Count() , 30,130
End Function

To add those players to our ALL list, we need a new function inside the TYPE TPlayer:

Type TPlayer
    Global All:TList = New TList
    Field GNetObj:TGNetObject
   
    ...   
    Function AddOthers(Obj:TGNetObject)
        Local loc:TPlayer = New TPlayer
        loc.GObj = Obj
        All.AddLast loc
    End Function


Here is a complete runnable code:

SuperStrict
Graphics 400,300
Global Host:TGNetHost=CreateGNetHost()
Local Name:String
If GNetListen(Host,12345) = True
    Name="SERVER"
Else
    If GNetConnect(Host,"127.0.0.1",12345)=False
        Name ="Error"
    Else
        Name="Client"
    EndIf
EndIf

Global Me:TPlayer = TPlayer.AddMe(Name)

Repeat
    Cls
    GNetSync(Host)
    DrawText "HERE= " + Name, 150,10
    ScanGNet
    Flip 1
Until AppTerminate()
CloseGNetHost(Host)

Function ScanGnet()
    ' find new
    For Local obj:TGNetObject=EachIn GNetObjects( host, GNET_CREATED )
        TPlayer.AddOthers obj
    Next
    DrawText "TPlayers:" + TPlayer.All.Count() , 30,130
End Function


Type TPlayer
    Global All:TList = New TList
    Field GObj:TGNetObject
   
    Function AddMe:TPlayer(Name:String)
        Local loc:TPlayer = New TPlayer
        loc.GObj = CreateGNetObject(Host)
        All.AddLast loc
        Return loc
    End Function
   
   
    Function AddOthers(Obj:TGNetObject)
        Local loc:TPlayer = New TPlayer
        loc.GObj = Obj
        All.AddLast loc
    End Function

End Type
...back from Egypt

Midimaster

#3
Lesson IV: Inform the others about player properties


Now we are closed to the final step. All properties, movements and states of our player need to be sent to the GNET to inform others about us:

Normally this is done like this:
Global Name:String = "Peter"
Global X:Int = 200
Global Y:Int = 100

CONST GNET_NAME = 1
CONST GNET_X    = 2
CONST GNET_Y    = 3

SetGNetString GObj, GNET_NAME, Name
SetGNetInt    GObj, GNET_X  , X
SetGNetInt    GObj, GNET_Y  , Y
...
DrawText Name, X, Y
Here we have three variables. To transfer them via GNet we define 3 symbolic CONSTANTs for better reading the code. In GNet you can use upto 32 channels (SetGNet...() GetGNet...() )for sharing upto 32 players variables.


In our example we transmit the variables when connecting the player to the GNET:

Type TPlayer
    ...
   
    Function AddMe:TPlayer(Name:String)
        Local loc:TPlayer = New TPlayer
        loc.GObj = CreateGNetObject(Host)
        SetGNetString loc.GObj, GNET_NAME, Name
        SetGNetInt    loc.GObj, GNET_X  , Rand(50,350)
        SetGNetInt    loc.GObj, GNET_Y  , Rand(50,250)
        All.AddLast loc
        Return loc
    End Function

Now GNET knows where our player is and what is his name. Now the others (and me) need only to draw the members of the ALL-list to display the whole scenery:

Type TPlayer
    ...

    Function DrawAll()
        For Local loc:TPlayer = EachIn All
            loc.Draw
        Next
    End Function
   
    Method Draw()
        DrawText "Player:" + Name() , X() , Y()       
    End Method

The gimmick is that X Y and Name are no fields but Methods(). So they can be investigated from GNET each time we need them:

Type TPlayer
...
    Method Name:String()
        Return GetGNetString(GObj,GNET_NAME)
    End Method
   
    Method X:Int()
        Return GetGNetInt(GObj,GNET_X)
    End Method

    Method Y:Int()
        Return GetGNetInt(GObj,GNET_X)
    End Method

This will display all player on their correct position at the screen. 


Complete runnable example: 

Compile this app, move the app window a little bit to the right and start the app a seond time from the project folder.


SuperStrict

Const GNET_NAME:Int =1
Const GNET_X:Int    =2
Const GNET_Y:Int    =3

Graphics 400,300
Global Host:TGNetHost=CreateGNetHost()

Local Name:String
If GNetListen(Host,12345) = True
    Name="SERVER"
Else
    If GNetConnect(Host,"127.0.0.1",12345)=False
        Name ="Error"
    Else
        Name="Client"
    EndIf
EndIf

Global Me:TPlayer = TPlayer.AddMe(Name)

Repeat
    Cls
    GNetSync(Host)
    DrawText "HERE=" + Name, 150,10
    TPlayer.DrawAll
    ScanGNet
    Flip 1
Until AppTerminate()
CloseGNetHost(Host)


Function ScanGnet()
    ' find new
    For Local obj:TGNetObject=EachIn GNetObjects( host, GNET_CREATED )
        TPlayer.AddOthers obj
    Next
    DrawText "TPlayers:" + TPlayer.All.Count() , 30,130
End Function


Type TPlayer
    Global All:TList = New TList
    Field GObj:TGNetObject
   
    Function AddMe:TPlayer(Name:String)
        Local loc:TPlayer = New TPlayer
        loc.GObj = CreateGNetObject(Host)
        SetGNetString loc.GObj, GNET_NAME, Name
        SetGNetInt    loc.GObj, GNET_X  , Rand(50,350)
        SetGNetInt    loc.GObj, GNET_Y  , Rand(50,250)
        All.AddLast loc
        Return loc
    End Function
   
   
    Function AddOthers(Obj:TGNetObject)
        Local loc:TPlayer = New TPlayer
        loc.GObj = Obj
        All.AddLast loc
    End Function


    Function DrawAll()
        For Local loc:TPlayer = EachIn All
            loc.Draw
        Next
    End Function
   
    Method Draw()
        DrawText "Player:" + Name() , X() , Y()       
    End Method
   
    Method Name:String()
        Return GetGNetString(GObj,GNET_NAME)
    End Method
   
    Method X:Int()
        Return GetGNetInt(GObj,GNET_X)
    End Method

    Method Y:Int()
        Return GetGNetInt(GObj,GNET_Y)
    End Method
End Type
...back from Egypt

Midimaster

#4
Lesson V: Moving the players during the game

So...  this all means that we do not need FIELDS to move the players or do action. We simply report new values to the GNET, which publish the informations to all members (also back to me) and the drawing function will draw the scenery depending only on the information it received from the GNET. So you can be sure, that all screens are always in the same state.

Here I show you as a latest step a moving simulation of the players. Depending on the window you have active you can move the player with the Mouse:

Repeat
    Cls
    GNetSync(Host)
    DrawText "HERE=" + Name, 150,10

'****** new line:************
    MoveMe

    TPlayer.DrawAll
    ScanGNet
    Flip 1
Until AppTerminate()

Function MoveMe()
    If MouseDown(1)
            Me.Move MouseX(), MouseY()
    EndIf
End Function

Type TPlayer
    ...

    Method Move(X:Int, Y:Int)
        SetGNetInt GObj, GNET_X , X
        SetGNetInt GObj, GNET_Y , Y   
    End Method



GNetTPlayer.gif
...back from Egypt

William

#5
I could not find a scangnet function in the gnet api documents, is it a blitz runtime library function or is it a user created function?

i dont know. edit: oh you posted the function in a later post. thank you.
im still interested in oldschool app/gamedev

William

#6
X Y Z fields are necessary, to compare the players previous coordinates to calculate the  of the distance the player moved to move the entity, at least i believe.. instead of positionentity each time. for MoveEntity. and i think also positionEntity may bypass the  b3d collision system. manual collisions may eat up cpu/app resources and lower fps.
im still interested in oldschool app/gamedev

Midimaster

You will have X,Y and Z also in a GNet-based game. But not as FIELD but as METHOD. The methods can be used in the same way like the fields, and just as often as you need.

Your actor (player) needs do to exacltly the same processes on all client machines. If you move the player  on our computer with MoveEntity(), but on other clients it will be moved with PositionEntity... this will result in different scenes. 

Example: On your computer you try to prevent the "falling under the terrain" with MoveEntity(). But on the other clients you use  PositionEntiy()  to move the Entity? This will result in falling on the other clients!
...back from Egypt

Alienhead

Quote from: Midimaster on July 16, 2023, 02:44:52Example: On your computer you try to prevent the "falling under the terrain" with MoveEntity(). But on the other clients you use  PositionEntiy()  to move the Entity? This will result in falling on the other clients!
Doubtful since there shouldn't be any collision/physics on the client side via 'other' players.  Positionentity just sets the exact position the 'other' player is at on their app side.

You'll probably want to tween that Position though as it'll look pretty jumpy to other clietns if your just updating with Positionentity() all the time. Perhaps some delta movement would be better.

Midimaster

#9
Lesson VI: Closing Clients and Server

When a client quits he needs to inform GNET that the Object is not longer alive. After this he can close the Host and quit.

On the server-side we need a function to read this "good-bye" messages and we need a reaction in TPlayer, which removes a player from the list.


Client quits

This is how a client says good-bye:

Repeat
    ....
Until AppTerminate()

CloseGNetObject(Me.GObj)
Delay 500
CloseGNetHost(Host)
End


And the server needs to scan the list of closing clients:

Function ScanGnet()

    ' find new clients:
    For Local obj:TGNetObject=EachIn GNetObjects( host, GNET_CREATED )
        TPlayer.AddOthers obj
    Next

    ' find leaving clients:
    For Local obj:TGNetObject=EachIn GNetObjects( host, GNET_CLOSED )
        TPlayer.Remove obj
    Next
End Function


And this is the reaction of TPlayer:

Type TPlayer
    Global All:TList = New TList
    Field GObj:TGNetObject

    ...

    Function Remove(obj:TGNetObject)
        For Local loc:TPlayer = EachIn All
            If loc.GObj=obj
                All.Remove loc
            EndIf
        Next
    End Function
End Type


Server quits

To find out, when the server quits is more tricky. We use one of the 32 slots to tell the others who the server is. As long as the server is running his "Slot 31" is TRUE. When the servers wants to quit, he closes his GNetObject. This also closes his "Slot 31". The other clients cannot find a Slot 31, which is TRUE and after a waiting period of 1000msec they also quit:


This defines the slot and it's value
Const GNET_SERVER_SLOT:Int =31 ' <----- THIS IS NEW **********
Global I_Am_Server:Int=False ' <----- THIS IS NEW **********

If GNetListen(Host,12345) = True
    Name="SERVER"
    I_Am_Server = True  ' <----- THIS IS NEW **********
Else
    ....


All members create a GNetObject and define a "Slot 31", but only one of them contains the value TRUE:

Type TPlayer
    Global All:TList = New TList
    Field GObj:TGNetObject
 
    Function AddMe:TPlayer(Name:String)
        Local loc:TPlayer = New TPlayer
        loc.GObj = CreateGNetObject(Host)
        SetGNetInt(loc.GObj, GNET_SERVER_SLOT, I_Am_Server ) ' <----- THIS IS NEW **********
        All.AddLast loc
        Return loc
    End Function


Now the others listen to this TRUE. Each time they find a "TRUE" slot31 they move the TimeOut into the future:

Function ScanGnet()
    ...

    For Local loc:TGnetObject=EachIn GNetObjects(Host,GNET_ALL )
        If GetGNetInt(loc, GNET_SERVER_SLOT)=TRUE
            DrawText "Server is Alive",30,160
            TimeOut = MilliSecs()+1000
            Return
        EndIf
    Next
End Function


So they TimeOut can never happen as long as the TRUE is found.

But when it happens, the main loop will be left:

Global    TimeOut:Int = MilliSecs()+1000
Repeat
    Cls
    GNetSync(Host)
    DrawText "HERE= " + Name, 150,10
    ScanGNet
    Flip 1
Until AppTerminate() Or TimeOut<MilliSecs() ' <----- THIS IS NEW **********

CloseGNetObject(Me.GObj)
Delay 500
CloseGNetHost(Host)
end






Here is the complete code:

SuperStrict
Graphics 400,300
Global Host:TGNetHost=CreateGNetHost()
Local Name:String

Const GNET_SERVER_SLOT:Int =31
Global I_Am_Server:Int=False

If GNetListen(Host,12345) = True
    Name="SERVER"
    I_Am_Server = True
Else
    If GNetConnect(Host,"127.0.0.1",12345)=False
        Name ="Error"
    Else
        Name="Client"
    EndIf
EndIf

Global Me:TPlayer = TPlayer.AddMe(Name)

Global    TimeOut:Int = MilliSecs()+1000
Repeat
    Cls
    GNetSync(Host)
    DrawText "HERE= " + Name, 150,10
    ScanGNet
    Flip 1
Until AppTerminate() Or TimeOut<MilliSecs()

CloseGNetObject(Me.GObj)
Delay 500
CloseGNetHost(Host)
End


Function ScanGnet()
    ' find new
    For Local obj:TGNetObject=EachIn GNetObjects( host, GNET_CREATED )
        TPlayer.AddOthers obj
    Next
    For Local obj:TGNetObject=EachIn GNetObjects( host, GNET_CLOSED )
        TPlayer.Remove obj
    Next
    DrawText "GNetObjects:" + GNetObjects(Host,GNET_ALL ).Count() , 30,100
    DrawText "TPlayers:" + TPlayer.All.Count() , 30,130

    For Local loc:TGnetObject=EachIn GNetObjects(Host,GNET_ALL )
        If GetGNetInt(loc, GNET_SERVER_SLOT)=True
            DrawText "Server is Alive",30,160
            TimeOut=MilliSecs()+1000
            Return
        EndIf
    Next
End Function


Type TPlayer
    Global All:TList = New TList
    Field GObj:TGNetObject
 
    Function AddMe:TPlayer(Name:String)
        Local loc:TPlayer = New TPlayer
        loc.GObj = CreateGNetObject(Host)
        SetGNetInt(loc.GObj, GNET_SERVER_SLOT, I_Am_Server )
        All.AddLast loc
        Return loc
    End Function
 
 
    Function AddOthers(Obj:TGNetObject)
        Local loc:TPlayer = New TPlayer
        loc.GObj = Obj
        All.AddLast loc
    End Function


    Function Remove(obj:TGNetObject)
        For Local loc:TPlayer = EachIn All
            If loc.GObj=obj
                All.Remove loc
            EndIf
        Next
    End Function
End Type

...back from Egypt

William

@Midimaster can you tell me if the server creates a gnet object for some purpose?
im still interested in oldschool app/gamedev

Midimaster

The GNetObject which you can create on the "server" is nothing more than another player like on the clients.

The GNetObject on the "server" has NOT more rights than the other client players. And it cannot do typical job we would call "master-jobs": excluding players or manipulate player' properties, etc...

Is that what you asked for?
...back from Egypt

William

Quote from: Midimaster on July 24, 2023, 15:00:20The GNetObject which you can create on the "server" is nothing more than another player like on the clients.

The GNetObject on the "server" has NOT more rights than the other client players. And it cannot do typical job we would call "master-jobs": excluding players or manipulate player' properties, etc...

Is that what you asked for?
yes i do not know of a particular example i had thought a particular use only that say the server crashes or something, a timeout for both client and server.
im still interested in oldschool app/gamedev

Midimaster

There is no build-in timeout-feature. As I demonstrated in Lesson VI you can add a "QUIT"-feature by using one of the 32 SLOTs.

And somebody suggested to add another SLOT, where everybody sends a timestamp every 1sec. If now in one of the GNetObjects the time does not move for a period of 5 seconds, the other can guess, that this user computer has crashed .
...back from Egypt

Midimaster

#14
Lesson VII

A server and 2 clients enable a 3 players game. Each player gets an ID related to the number of members. The clients are monitoring the number of members and monitoring the existence of the server.

move the mouse in each window and watch how the text are moving in the other windows:

SuperStrict
Graphics 400,300
Global Host:TGNetHost=CreateGNetHost()
Local Name:String

Const GNET_SERVER_SLOT:Int =31
Const GNET_NAME:Int = 1
Const GNET_X:Int    = 2
Const GNET_Y:Int    = 3
Const GNET_ID:Int   = 4

Global I_Am_Server:Int=False
Global IsAliveText:String, TimeRemain:String

If GNetListen(Host,12345) = True
    Name="SERVER"
    I_Am_Server = True
Else
    If GNetConnect(Host,"127.0.0.1",12345)=False
        Name ="Error"
    Else
        Name="Client"
    EndIf
EndIf

Global Me:TPlayer = TPlayer.AddMe(Name)

Global    TimeOut:Int = MilliSecs()+5000
Repeat
    Cls
    SetColor 255,255,255
    DrawText IsAliveText + TimeRemain  ,  30,160
    ScanGNet
    MoveMe
    TPlayer.DrawAll
    ChangeColor Me.Id()
    DrawText "HERE= " + Name, 150, 10
    Flip 1
Until AppTerminate() Or TimeOut<MilliSecs()

CloseGNetObject(Me.GObj)
Delay 500
CloseGNetHost(Host)
End


Function ScanGnet()
    ' find new
    GNetSync(Host)
    For Local obj:TGNetObject=EachIn GNetObjects( host, GNET_CREATED )
        TPlayer.AddOthers obj
    Next
    For Local obj:TGNetObject=EachIn GNetObjects( host, GNET_CLOSED )
        TPlayer.Remove obj
    Next
    DrawText "GNetObjects:" + GNetObjects(Host,GNET_ALL ).Count() , 30,100
    DrawText "TPlayers:" + TPlayer.All.Count() , 30,130
    IsAliveText = "Server is dead"
    TimeRemain  = "  (timeout in:" + Int((timeOut-MilliSecs())/1000) + "sec)"
    For Local loc:TGnetObject=EachIn GNetObjects(Host,GNET_ALL )
       
        If GetGNetInt(loc, GNET_SERVER_SLOT)=True
            IsAliveText="Server is Alive"
            TimeOut=MilliSecs()+5000
            Return
        EndIf
    Next
End Function


Function MoveMe()
    If MouseDown(1)
            Me.Move MouseX(), MouseY()
    EndIf
End Function



Type TPlayer
    Global All:TList = New TList
    Field GObj:TGNetObject

    Method Move(X:Int, Y:Int)
        SetGNetInt GObj, GNET_X , X
        SetGNetInt GObj, GNET_Y , Y  
    End Method

    Function DrawAll()
        For Local loc:TPlayer = EachIn All
            loc.Draw
        Next
    End Function
  
    Method Draw()
        ChangeColor ID()
        DrawText "Player:" + Name() , X() , Y()      
    End Method
  
    Method Name:String()
        Return GetGNetString(GObj,GNET_NAME)
    End Method
  
    Method X:Int()
        Return GetGNetInt(GObj,GNET_X)
    End Method

    Method Y:Int()
        Return GetGNetInt(GObj,GNET_Y)
    End Method

    Method ID:Int()
        Return GetGNetInt(GObj,GNET_ID)
    End Method
 

    Method Define(Nr:Int, Nam:String)
        Nam = Nam +" (ID=" +Nr + ")"
        SetGNetInt    GObj, GNET_ID         , Nr
        SetGNetString GObj, GNET_NAME       , Nam
    End Method

   
    Function AddMe:TPlayer(Name:String)
        Local loc:TPlayer = New TPlayer

        ' first fast setup
        loc.GObj = CreateGNetObject(Host)
        SetGNetInt(loc.GObj, GNET_SERVER_SLOT, I_Am_Server )
        loc.Define 0,""
        loc.move Rand(300),Rand(250)
        All.AddLast loc
       
        ' second exact setup
        ScanGnet()
        Delay 500
        ScanGnet()
        loc.Define GNetObjects(Host,GNET_ALL ).Count() , Name
        Return loc
    End Function
 
 
    Function AddOthers(Obj:TGNetObject)
        Local loc:TPlayer = New TPlayer
        loc.GObj = Obj
        All.AddLast loc
    End Function


    Function Remove(obj:TGNetObject)
        For Local loc:TPlayer = EachIn All
            If loc.GObj=obj
                All.Remove loc
            EndIf
        Next
    End Function
End Type


Function ChangeColor(ID:Int)
    Select ID
        Case 0
            SetColor 77,77,77
        Case 1
            SetColor 255,255,0
        Case 2
            SetColor 0,255,255       
        Case 3
            SetColor 255,0,255
    End Select
End Function
...back from Egypt