Programmare un Gioco Online

Paolo Medici
Questo documenti affronta i problemi teorici e pratici per sviluppare qualsiasi applicazione su Internet e in particolare Videogiochi. Ho usato terminologie terra terra sperando di essere più chiaro possibile a un publico abbastanza ampio. Questo documento spiega come costruire una qualsiasi applicazione su Internet partendo da zero in pochissimo tempo.

La rete. Terminologia

La rete è tutto ciò che sta in mezzo tra due computer che vogliono comunicare. Tutto ciò che verrà detto perciò è uguale sia che si tratti di una LAN (Local Area Network) o che si tratti di una MAC (Metropolitan Area Network) o WAN (* Area Network). L'unica differenza sta nella quantità di dati per secondo che queste diverse reti possono trasportare, per esempio la LAN al giorno d'oggi possono arrivare fino a 1Gb/sec, le MAN 140Mb/s e le WAN spesso a solo 2Mb/s attraverso un satelitte. La rete permette di trasferire dati da due computer in modo trasparente all'utente, in modo tale che questo veda il computer remoto come direttamente connesso al locale.
L'utente si trova alla cima di una piramide (ISO/OSI), ma anche programmatore al giorno d'oggi riesce senza troppi problemi a usare una connessione remota grazie a un set di API comuni su sistemi operativi e macchine diverse come i Socket.

[continua...]

Usare e Inizializzare la libreria.

Per usare i Socket sotto Win32 è facile: basta linkare la libreria ws2_32.lib e includere nel progetto l'header winsock.h o meglio ancora winsock2.h.
Per inizializzare i Socket il procedimento è lo stesso semplice. Nella fase di startup del programma (in qualunque punto prima di accedere a funzioni di WinSock) bisogna controllare la versione di Winsock installata:
WSAStartup (uan WORD indicante la versione di WinSock richiesta, un PUNTATORE A UNA STRUTTURA WSADATA che verrà riempita con informazioni);
esempio:
WSADATA wsadata;
int error = WSAStartup (0x0202, &wsadata);
if (error)
  return FALSE;
In questo modo verrà richiesta la versione 2.2 del WinSock. La libreria verrà inizializzata e sarà possibile lavorare.

Al momento dell'uscita del programma sarà necessario liberare queste informazioni.
Semplicemente:
WSACleanup();

Queste due funzioni di inizializzazione ovviamente non sono presenti (ne necessarie) a usare i socket sono Unix/Linux. Sotto queste piattaforme linkare due librerie: (socket e nls) e includere le librerie: netinet/in.h, arpa/inet.h (altamente opzionale) e netdb.h. In generale tutte le funzioni che cominciano con WSA sono Windows Dependent e non esistono nel subset dei socket della Berkley.

Terminologia.

SOCKET

Un Socket è un descrittore (un numero) che indica una particolare linea di comunicazione aperta in entrata o in uscita sul proprio computer.

Protocolli

I Protocolli di rete sono strutture in cui i dati vengono trasferite sulla rete. Non è importante come siano implementate a livello di scheda, ma piuttosto come questi possono essere utilizzati allo scopo.
Esistono due principali tecniche che si basano su altrettanti protocolli per far parlare due computer: il TCP o l'UDP. Esistono ovviamente altri protocolli ma non importanti ai nostri fini. Con questi due protocolli è possibile sviluppare qualsiasi cosa.

TCP

Il Tcp è il formato più usato su Internet. Lo usano tutti i Browser, Ftp, Telnet, News, Pop eccetera.
E' un protocollo affidabile e molto rigoroso, ma lento, pesante e poco flessibile. Il TCP crea un canale di comunicazione bidirezionale tra due computer e il canale deve restare aperto per tutta la durata della comunicazione, visto che aprireo e chiudere una sessione TCP è molto lento. Quando aperta i dati trasferiti e ricevuti sono affidabili, i pacchetti arrivano sempre a destinazione nell'ordine di invio. Questo perchè il TCP aspetta un segnale di ricezione prima di trasmettere i dati successivi. Questo protocollo va bene fino a un centinaio di connessioni contemporaneamente.
Per creare un SOCKET TCP (anche chiamato Stream Socket) basta scrivere:
SOCKET s = socket (AF_INET, SOCK_STREAM, 0);

La variabile tipo SOCKET sotto Unix non esiste: o si usa int al suo posto o si fa un elegante typedef:
typedef SOCKET int;

UDP

L'UDP invece è totalmente l'opposto. E' un protocollo veloce, ad alte prestazione, ma inaffidabile. Ovvero i pacchetti possono andar perduti nella comunicazione, arrivare addirittura doppi, o arrivare in tempi diversi rispetto a quelli di invio. Il vantaggio è che non viene aperto un canale privato tra due computer, ma i pacchetti viaggiano indisturbati sulla rete. Il computer che trasmette non aspetta che i pacchetti siano arrivati, ma si limita solo a trasmetterli. In questo modo sul computer che trasmette non c'è bisogno di avere aperte centinaia di comunicazioni, ma al momento dell'invio dei dati basta indicare il codice del computer a cui si vuole inviare il pacchetto e sarà poi compito della Rete farglielo arrivare il prima possibile. In questo modo si possono avere infinite connessioni contemporaneamente senza vedere un appesantimento (fisso) delle prestazioni sulla comunicazione (indipendentemente dalla quantità dei dati trasferiti).
Per creare un SOCKET UDP (anche chiamato Datagram Socket) basta scrivere:
SOCKET s = socket (AF_INET, SOCK_DGRAM, 0);

La dimensione del singolo pacchetto è importante (anche perchè l'UDP, all'opposto del TCP, rispetta le dimensioni del dato inviato). Più il pacchetto è grande meno sono le probabilità che arrivi a destinazione. Una dimensione di 2K per un pacchetto, da test empirici, da me effettuati è stata l'ultima affidabile (con banda di uscita uguale alla massima supportata dal modem). Per esempio inviare un pacchetto di 8K arriva a un destinatario distante 4/5 nodi con probabilità del 33%. Il metodo più rapido per rendere affidabile l'UDP è spedire più pacchetti uguali (il destinatario dovrà avere un sistema per eliminare pacchetti già ricevuti, per esempio tramite un ID inviato con il pacchetto), sempre che questo procedimento non si scontri con la limitatezza di banda in uscita del server.

Multicast

Il Multicast è una tecnica che permette di ridurre drasticamente la larghezza delle banda utilizzata in uscita da un Server. Questa tecnica è applicata al caso di trasmissioni a un elevato numero di utenti dello stesso pacchetto di dati. Invece che inviare a ogni utente il pacchetto (cosa che occuperebbe n*size) si invia al router della rete il pacchetto con le indicazioni delle destinazioni e sarà compito del router e dei router che seguiranno smistare correttamente il dato ai destinatari. Ovviamente questa tecnica è ottima per trasmissioni video, dove l'utente ha una scarsa interazione con il server e il server deve trasmettere a molti utenti una quantità elevata di dati. A mio parere in un gioco queste condizioni non si verificano quasi mai... comunque potrebbe divenire vantaggioso spedire agli utenti anche dati non voluti, ma che in seguito potrebbero desiderare... bof...
Come implementarlo: appena sono sicuro ve lo vaccio sapere... in reti locali è stato provato e funzionate.... su Internet non tutti i nodi sono stati ancora progettatti per il multicast e prove non sono ancora state possibili.

[continua...]

Indirizzo IP

L'indirizzo IP è un array di numeri (4 o 8 a seconda della versione) che indicano un'unica macchina su tutta la rete (LAN o WAN che essa sia). Alcuni indici sono riservati per uso interno del computer o della rete. E' da tenere a mente che questo numero può essere fisso o assegnato di volta in volta.

DNS

Il DNS (Domain Name Server) è un servizio svolto a livello di rete (locale o globale) che permette di convertire una stringa nell'indirizzo IP del computer corrispondente. Questo servizio è disponibile direttamentre via software (gethostbyname) ed è molto semplice da implementare.

Client e Server

Il Server è il computer che riceve e invia i dati di computer Client collegati tra di loro. I Client non si vedono tra di loro in un'architettura tra Client e Server, ma è il Server centrale che smista i messaggi tra loro, filtrandoli o modificandoli.

Servizi e porte

I Servizi sono programmi che si mettono in attesa di connessioni sul Server. Questi programmi si mettono in ascolto su una porta (virtuale) di comunicazione sul computer. Una porta è un numero (0-65535) che indica un unico servizio su quel computer e su un computer possono aperte quante porte (diverse) si vogliono. In questo modo è possibile che più programmi (servizi) possano girare sullo stesso computer contemporaneamente anche se svolgono funzioni nettamente diverse con la rete. Per esempio un Server può fare da Server TCP, HTTP, Telnet contemporaneamente, anche se i programmi inviano dati differenti allo stesso indirizzo IP. Il Servizio a cui si vuole parlare non può essere cambiato durante una comunicazione neanche usando l'UDP. Quando selezionato sul Client e messo in ascolto sul Server i dati verranno inviati automaticamente all'applicazione corretta senza nessun controllo da parte dell'utente. L'applicazione Client e l'applicazione Server devono ovviamente parlare usando lo stesso protocollo.
Alcuni numeri di porte sono riservati, comunque i numeri sopra il 1024 non sono normalmente riservati a servizi specifici.
Alcuni esempi (le definizioni possono essere trovate anche in winsock.h):
 
Porta Servizio
21 Telnet.
25 SMTP
80 HTTP
Per tutti questi servizi è sottointeso che venga usato il protocollo TCP.

Close Socket

Per chiudere un descittore, precedentemente creato con socket, sotto Windows si usa la funzione closesocket, mentre, visto che in Unix il socket è un descrittore standard di sistema, si usa la close standard.

Collegare il Client

Dopo aver inizializzato la libreria, e creato un socket con il protocollo scelto (il Client e il Server devono usare lo stesso protocollo), per collegare i due computer biosgna sapere 2 cose:
  1. L'indirizzo IP o il nome del computer remoto
  2. La porta in cui il programma sul Server è in Ascolto
Questi dati sono imposti da chi ha programmato il programma Server e il nome o l'indirizzo IP devono essere Fissi (o comunicati in altro modo all'utente).

(la variabile s (di tipo SOCKET) deve essere inizializzata con il protocollo scelto)
(server_name (stringa) deve contenere il nome o l'indirizzo IP del server)
(wPort (WORD) deve contenere il numero della porta del servizio)
sockaddr_in target;
u_long addr = inet_addr (server_name);
if (addr == INADDR_NONE) {
 // server_name non è un indirizzo IP, proviamo a usare il DNS
 hostent* HE = gethostbyname(server_name);
 if (HE == 0) {
  closesocket(s);
  // Errore: Host non trovato
  return INVALID_SOCKET;
  }
 addr = *((u_long*)HE->h_addr_list[0]);
 }

Adesso addr contiene un numero che è l'indirizzo IP dell'Host. La procedura successiva collega il Client al Server. Se si stà usando UDP questa procedura non è necessaria, ma comunque non da errore. L'unica comodità in questo modo per UDP è che si può usare send invece di sendto, visto che in questo modo si seleziona il server di default per inviare i messaggi.

target.sin_family = AF_INET;       // address family Internet
target.sin_port = htons (wPort);     // set serverís port number
target.sin_addr.s_addr = addr;  // set serverís IP
if (connect(s, (SOCKADDR*) &target, sizeof(target)) == SOCKET_ERROR)
 {
 // Errore di comunicazione
 closesocket(s);
 return INVALID_SOCKET;
 }

La funzione htons (Host to Network) converte un numero nel formato del computer locale in quello della rete (è stato scelto il Big-Endian). Anche se si sa che il computer locale è Big-Endian è ottima cosa usare lo stesso questa funzione, che in tal caso sarà solo una macro vuota.

Conclusione. Il client deve Connettersi con un Server: ConnectToServer

SOCKET ConnectToServer(char *server_name, WORD wPort)
{
#ifdef USEUDP
 SOCKET s = socket (AF_INET, SOCK_DGRAM, 0); // Create Datagram Socket
#else
 SOCKET s = socket (AF_INET, SOCK_STREAM, 0); // Create Stream Socket
#endif
sockaddr_in target;
u_long addr = inet_addr (server_name);

if (addr == INADDR_NONE) {
 // Host isn't an IP address, try using DNS
 hostent* HE = gethostbyname(server_name);
 if (HE == 0) {
  closesocket(s);
  // error: Unable to parse!
  return INVALID_SOCKET;
  }
 addr = *((u_long*)HE->h_addr_list[0]);
 }

target.sin_family = AF_INET;        // address family Internet
target.sin_port = htons (wPort);     // set serverís port number
target.sin_addr.s_addr = addr;  // set serverís IP

if (connect(s, (SOCKADDR*) &target, sizeof(target)) == SOCKET_ERROR)
 {
  // an error connecting has occurred!
 closesocket(s);
 return INVALID_SOCKET;
 }

return s;
}
 

Socket

I Socket permettono 3 divesi metodi per gestire i dati che arrivano dalla rete. Infatti il programma deve continuare a eseguire operazioni quando non arrivano informazioni dalla rete e in ogni caso il sistema operativo deve restare in esecuzione.
Dunque si è dovuto implementare diversi metodi per essere più o meno asincroni rispetto agli eventi della rete: Ovviamente non esiste una configurazione ottimale ma dipende dal tipo di programma ed è diversa tra Client e Server.
Solitamente sul Server si scelgono socket bloccanti e sul client asincroni, ma è solo una traccia.

IO di base

Le funzioni di IO di base sono molto semplici: recv e send per il TCP e recvfrom  e sendto specifici l'UDP, ma non è sempre vero...

Ecco i prototipi di queste funzioni:
 
int recv (
  SOCKET s, 
  char FAR* buf, 
  int len, 
  int flags 
);
int recvfrom (
  SOCKET s, 
  char FAR* buf, 
  int len, 
  int flags, 
  struct sockaddr FAR* from, 
  int FAR* fromlen 
); 
int send (
  SOCKET s, 
  const char FAR * buf, 
  int len, 
  int flags 
);
int sendto (
  SOCKET s, 
  const char FAR * buf, 
  int len, 
  int flags, 
  const struct sockaddr FAR * to, 
  int tolen 
);

Vediamo in dettaglio (a mio avviso) quali funzioni è meglio usare:
 
Funzione Significato Client Server
recv riceve i dati da un socket da cui non è importante conosce l'indirizzo. Visto che i dati dovrebbero arrivare sempre dal server questa funzione è sufficiente. (TCP) E' utile.
(UDP) Non fornisce sufficienti informazioni.
recvfrom Riceve i dati e fornisce l'indirizzo del computer che li ha inviati (TCP) Non è utile.
(UDP) Comunicazioni Peer To Peer
(UDP) E' sempre necessario conoscere quale computer invia i dati.
(TCP) E' inutile. L'indirizzo è ridondante.
send Invia dati all'indirizzo indicato dal socket. E' sufficiente. Il socket contiene le informazioni per comunicare con il server. (TCP) E' indispensabile.
(UDP) è limitato: invia a un solo target
sendto Invia dati a un indirizzo specificato. (TCP) Non è utile.
(UDP) Comunicazioni Peer To Peer
(TCP) L'indirizzo viene ignorato.
(UDP) E' l'unica via per comunicare con i client.

Ovviamente se si utilizzano comunicazioni peer-to-peer è necessario sempre usaro recvfrom e sendto.
Sotto Unix, visto che i Socket sono sempre descrittori, è possibile usare le funzioni read e write tipiche delle operazioni su file (o stream in generale), ovviamente solo se il protocollo utilizzato è di tipo STREAM (come lo è ovviamente il TCP). Sotto Windows, d'altro canto, i Socket sono degli Handle, e rende possibile usare le funzioni ReadFile e WriteFile, con gli stessi accorgimenti di avere una connessione stream.

Socket Sincroni. Server.

Sul server è utile creare un Thread a parte per la ricezione dei messaggi dagli utenti.

int ServerThreadUdp(SOCKET sServer)
{
 struct sockaddr_in from;
 int fromlen;
 int retval;

 fromlen =sizeof(from);
 retval = recvfrom(sServer,
  recbuffer,
  RECBUFFER_SIZE,
  0,
  (struct sockaddr *)&from,
  &fromlen);
... etc ...
}

Il server in ogni caso è un tipico esempio di MultiThreading e di sincronia dei dati. Mentre un thread è in ascolto, un'altro deve compiere delle azioni e notificarle man mano agli utenti, basandosi su dati che nel frattempo potrebbero essere stati modificati.

Se per caso vogliamo sviluppare un gioco di ruolo a turni (tipo ogni 10 secondi) potremo creare un Timer che viene chiamato con questa cadenza e da questo trasmettere agli utenti i risultati delle azioni.

[continua]

Socket Asincroni. Client e UDP.

Cominciamo subito con un esempio pratico. Sono necessarie 3 cose:
  1. SOCKET sServer. Un socket connesso con un server, per esempio tramite la precedente procedura ConnectToServer.
  2. HWND hWnd. La finestra principale del programma a cui giungeranno messaggi di notifica quando arriveranno dei dati al programma
  3. una costante, WM_ONSOCKET,  una macro come in questo caso (per esempio WM_USER + 1), che indentifica il codice messaggio che verrà inviato alla finestra.
WSAAsyncSelect (sServer, hWnd, WM_ONSOCKET, (FD_READ | FD_CONNECT | FD_CLOSE));

In questo caso, quando arriveranno dei dati (FD_READ), o se verrà Chiusa la connessione dal Server, verrà generato un messaggio chiamato WM_ONSOCKET e inviato alla finestra hWnd. Ricordo ancora che WM_ONSOCKET è una macro definita dall'utente del tipo:
#define WM_ONSOCKET    (WM_USER+1)

A questo punto basta aggiungere alla procedura di controllo dei messaggi:

  case WM_ONSOCKET:
   if (WSAGETSELECTERROR(lParam))
    {
    // error
    OnSocketError(hwnd,WSAGETSELECTERROR(lParam));
    return 0;
    }
   switch (WSAGETSELECTEVENT(lParam))
   {
   case FD_READ:
    OnReceiveData(hwnd, (SOCKET)wParam);
    break;
   case FD_CONNECT:
    break;
   case FD_CLOSE:
    OnSocketClose(hwnd, (SOCKET)wParam);
    break;
    }
   return 0;

OnReceiveData sarà una funzione del tipo:

int OnReceiveData(HWND hwnd, SOCKET s)
{
 int iLen;
 iLen = recv(s, (char *) recbuffer, RECBUFFER_SIZE, 0);
 if(iLen == SOCKET_ERROR)
    // error
   return FALSE;
 ... etc ...
return TRUE;
}

mentre OnSocketClose verrà chiamato quando l'utente è stato disconnesso dal server: sarà una funzione del tipo:

void OnSocketClose(HWND hwnd, SOCKET s)
{
// flush data:
 while(OnReceiveData(hwnd, s)>0);
}
 
 



 
Paolo Medici, che ultimamente aveva del tempo da perdere in retorica. Dalla Serie delle Guide Veloci per fare Software Miliardari Queste sono considerazioni personali, dettate dall'esperienza pratica in anni di programmazione. Se avete domande o modifiche:
Questo articolo è ha avuto 15119 contatti. Pagina Aggiornata il 14 giugno 2001