Click here to Skip to main content
15,867,488 members
Articles / Game Development

Game Engine using SDL2 and ZetScript

Rate me:
Please Sign up or sign in to vote.
4.93/5 (24 votes)
10 Jul 2021MIT8 min read 28.1K   989   30   6
Example game engine by using SDL2 and ZetScript

Update: It is also possible run the demo with the following link: https://zetscript.org/demos/zs_ge/engine.html

Introduction

This article aims to show how easily you can build a simple game engine in C++ application with SDL2 and ZetScript script engine. The engine will have a few set of functions for paint graphics, read input keys and play sounds using Simple DirectMedia Layer(SDL). Later, we will show some pieces of script code about how to make a demo of Invader game.

Quote:

Note: This article is not going to go in depth detail of function implementations for the engine we are presenting in this article but it will show class headers and some pieces of code from main function to have a basic idea how everything fits together.

How to Use the Demo

In order to run the Invader demo game, execute the following steps:

  1. Decompress zs_ge-1.1.0-x86-win32.zip in some directory
  2. Drag invader.zs into engine.exe

Controls

  • Left/Right: Move ship
  • Space: Start game/shoot
  • F5: Reload script invader.zs
  • F9: Toggle fullscreen
  • ESC: Quit engine

Requirements

To compile the code, you need ZetScript library, SDL2 library, cmake application, and MinGW or Linux with gnu 4.8 compiler or MSVC 2015/2017 or build tools v141.

If you satisfy the requirements, go to the directory where you have decompressed the source code and do the following:

C++
cmake

After cmake operation, the configuration files will be created.

Quote:

Note: In case of configure project for MVC++, you also have to provide include paths and library paths in order to find SDL and ZetScript.

Engine

The version of the engine we are presenting in this article will draw graphics, sprites, fonts, will play sounds and will read input keys from keyboard. The engine has the following classes:

  • Input
  • Image
  • Sprite
  • Font
  • Render
  • Sound
  • SoundPlayer

Input

CInput class implements functions to read input keys.

C++
#define  T_ESC          Input::getInstance()->key[KEY_ESCAPE]
#define  T_F5           Input::getInstance()->key[KEY_F5]
#define  T_F9           Input::getInstance()->key[KEY_F9]
#define  T_SPACE        Input::getInstance()->key[KEY_SPACE]

#define  TR_UP          Input::getInstance()->keyR[KEY_UP]
#define  TR_LEFT        Input::getInstance()->keyR[KEY_LEFT]
#define  TR_RIGHT       Input::getInstance()->keyR[KEY_RIGHT]
#define  TR_DOWN        Input::getInstance()->keyR[KEY_DOWN]

typedef struct{
    Uint32 codeKey;
}EventKey,EventRepeatKey;

class Input{
    static Input *input;
    SDL_Event Event;

public:
    bool            key[KEY_LAST];
    bool            keyR[KEY_LAST];

    static CInput * getInstance();
    static void destroy();

    void update();

};
List 1.1

Image

CImage class implements functions to load bmp images and create dynamic images based on binary indices.

C++
#pragma once

#include   <SDL2/SDL.h>
#include <zetscript.h>

class Image{

protected:
    SDL_Texture *texture;
    int width,height;

    static SDL_Texture * SurfaceToTexture(SDL_Surface *srf);
    bool createSquarePixmap(const vector<int> & pixmap);
    void destroy();
public:
    Image();
    Image(const vector<int> & pixmap);

    bool load(const char *file);

    SDL_Texture *getTexture();

    // create image from script ...
    bool createSquarePixmap(zetscript::CVectorScriptVariable *vector);

    int getWidth();
    int getHeight();

    ~Image();
};
List 1.2

Sprite

CSprite class implements functions to setup and update sprites in a xy coordinate and paints it the current frame it has.

C++
#include "Image.h"

class Sprite{
    static int synch_time;

    unsigned current_frame;
    int current_time_frame,time_frame;

public:

    typedef struct{
        Image *image;
        Uint32 color;
    }FrameInfo;

    static void synchTime();

    int x, y;
    int dx, dy;
    int width, height;
    std::vector<FrameInfo> frame;

    static bool checkCollision(Sprite *spr1, Sprite *spr2);
    static bool checkCollision(int offset_x,int offset_y,CSprite *spr1, CSprite *spr2);

    CSprite();

    void addFrame(CImage *fr, int rgb);
    void setFrame(int n);
    void setTimeFrame(int time);
    int getWidth();
    int getHeight();
    Sprite::FrameInfo * getCurrentFrame();

    void update();

    ~Sprite();

};
List 1.3

Font

CFont class implements functions to load bmp fonts. The user has to pass the dimension of each character in order to correctly align all characters it renders.

C++
#include "CImage.h"

class Font:public Image{

    int totalchars_x, totalchars_y,totalchars;
    SDL_Rect m_aux_rect;
    int char_width,char_height;
public:

    CFont();

    bool load(const char * file,int char_width,int char_height);

    int getCharWidth();
    int getCharHeight();
    int getTextWith(const string & str);
    SDL_Rect * getRectChar(char c);

    ~CFont();
};
List 1.4

Render

CRender class implements functions to paint images, sprites and texts using a font.

C++
class Render{

    int width, height;

    static Render *render;
    SDL_Renderer *p_renderer = NULL;
    SDL_Event event;
    bool fullscreen;

    SDL_Window* p_window = NULL;

    Render();
    ~Render();

public:
    static Render *getInstance();

    static void destroy();

    int getWidth();
    int getHeight();

    void createWindow(int width, int height);
    void toggleFullscreen();
    SDL_Renderer *getRenderer();
    SDL_Window *getWindow();

    void clear(Uint8 r, Uint8 g, Uint8 b);

    void drawImage(int x, int y, CImage *img);
    void drawImage(int x, int y, CImage *img, int color);

    void drawText(int x,int y, CFont * font, const char * text);

    void drawSprite(CSprite *spr);
    void drawSprite(int x, int y, CSprite *spr);

    void update();
};
List 1.5

CSound

CSound class implements function to load sound file (only supports wave).

C++
class Sound{

public:

    Uint32 wav_length;
    Uint8 *wav_buffer;

    Sound();

    bool load(string * file);

    ~Sound();

};
List 1.6

SoundPlayer

CSoundPlayer implements functions to play CSound objects and init the sound system.

C++
#define MAX_PLAYING_SOUNDS 20

class SoundPlayer{

    SDL_AudioSpec wav_spec;

    typedef struct {
        Uint8 *audio_pos;
        Uint32 audio_len;
    }SoundData;

    static SoundData SoundData[MAX_PLAYING_SOUNDS];

    static SoundPlayer * singleton;
    SDL_AudioDeviceID dev;
    SoundPlayer();
    ~SoundPlayer();

    static void callbackAudio(void *userdata, Uint8* stream, int len);

public:

    static SoundPlayer * getInstance();
    static void destroy();

    void setup(SDL_AudioFormat format=AUDIO_S16SYS, 
               Uint16 Freq=22050, Uint16 samples=4096, Uint8 channels=2);

    void play(CSound *snd);

};
List 1.6

Bind C++ Code to Use in Script

In the last sections, we have seen the classes that make up the functionality to manage graphics, input and sound for the game engine. This section and maybe the most exciting part, we will see how easy it is to bind main parts of the C++ headers to use in script side through ZetScript API.

We have register_C_Class and register_C_SingletonClass to register classes and singletons respectively. register_C_VariableMember, register_C_FunctionMember for registering its class members. Also, register_C_FunctionMember is used to register C functions.

For example, we register CImage to use in the script by doing the following:

C++
registerNativeClass<CImage>("CImage")

And in the similar way to register its function member CImage::load:

C++
registerNativeFunctionMember<CImage>("load",&CImage::load);

The following code shows all the functions and variables we need to be used in the script side. We will see that in a few lines of code, we have registered them all!

C++
// Binds CImage class...
registerNativeClass<CImage>("Image")) return false;

// bind a custom constructor...
registerNativeFunctionMember<Image>("Image",static_cast<bool
(Image::*)(zetscript::CVectorScriptVariable * )>(&Image::createSquarePixmap));
registerNativeFunctionMember<Image>("load",&Image::load);

// Binds Sprite class and it members...
registerNativeClass<Sprite>("Sprite");
registerNativeFunction("checkCollision",static_cast<bool
        (*)(int, int, Sprite *, Sprite *)>(&Sprite::checkCollision));
registerNativeFunction("checkCollision",static_cast<bool
        (*)(Sprite *, Sprite *)>(&Sprite::checkCollision));
registerNativeVariableMember<Sprite>("x",&Sprite::x);
registerNativeVariableMember<Sprite>("y",&Sprite::y);
registerNativeVariableMember<Sprite>("dx",&Sprite::dx);
registerNativeVariableMember<Sprite>("dy",&Sprite::dy);
registerNativeVariableMember<Sprite>("width",&Sprite::width);
registerNativeVariableMember<Sprite>("height",&Sprite::height);

// Sprite functions
registerNativeFunctionMember<Sprite>("setTimeFrame",&Sprite::setTimeFrame);
registerNativeFunctionMember<Sprite>("update",&Sprite::update);
registerNativeFunctionMember<Sprite>("addFrame",&Sprite::addFrame);

// Binds Font class
registerNativeClass<Font>("Font");
registerNativeFunctionMember<Font>("load",&Font::load);

// Binds Sound class
registerNativeClass<Sound>("Sound");
registerNativeFunctionMember<Sound>("load",&Sound::load);

registerNativeSingletonClass<Render>("Render");
registerNativeFunction("getRender",Render::getInstance);
registerNativeFunctionMember<Render>
("drawImage",static_cast<void (Render:: *)(int, int, Image *)>(&Render::drawImage));
registerNativeFunctionMember<Render>
("drawImage",static_cast<void (Render:: *)(int, int, Image *,int)>(&Render::drawImage));
registerNativeFunctionMember<Render>("drawText",&Render::drawText);
registerNativeFunctionMember<Render>
("drawSprite",static_cast<void (Render:: *)(int, int, Sprite *)>(&Render::drawSprite));
registerNativeFunctionMember<Render>
("drawSprite",static_cast<void (Render:: *)(Sprite *)>(&Render::drawSprite));
registerNativeFunctionMember<Render>("getWidth",&Render::getWidth);
registerNativeFunctionMember<Render>("getHeight",&Render::getHeight);

// Binds singleton SoundPlayer...
registerNativeSingletonClass<SoundPlayer>("SoundPlayer");
registerNativeFunction("getSoundPlayer",&SoundPlayer::getInstance);
registerNativeFunctionMember<SoundPlayer>("play",&SoundPlayer::play);

// Binds input global vars...
registerNativeVariable("TR_UP",TR_UP);
registerNativeVariable("TR_DOWN",TR_DOWN);
registerNativeVariable("TR_LEFT",TR_LEFT);
registerNativeVariable("TR_RIGHT",TR_RIGHT);
registerNativeVariable("T_SPACE",T_SPACE);
List 1.7

Load Script File

ZetScript provides a function to load script file called eval_file as we can see in the following code:

C++
zs.evalFile("file.zs");

Bind Script Functions

After the file is loaded, we need to expose the script functions from the loaded file to be used in game engine. The script file should implement these three functions:

  • init: will initialize variables, structs, sprites, load images, etc.
  • update: will be called in the C++ game engine loop
  • unload: will be called when the game is unloaded

To bind a script function is done by bind_function passing the function cast we want to be in the script side. Generally, all script function does won't pass any arguments and won't return any value so the function cast will be void(void).

To bind these functions is shown in the following code:

C++
std::function<void()> * init=zs.bindScriptFunction<void ()>("init");
std::function<void()> * update=zs.bindScriptFunction<void ()>("update");
std::function<void()> * unload=zs.bindScriptFunction<void ()>("unload");
List 1.8

Game Engine loop

Finally, we present the game engine loop. It would be the following code:

C++
render=Render::getInstance();
input=Input::getInstance();

(*init)(); // <-- it calls script function init.

do{

  render->clear(0,0,0); // clears screen with black color

  input->update(); // read events from keyboard.

  (*update)(); // <-- it calls script function update.

  render->update(); // flip screen

}while(!T_ESC);

(*unload)(); // <-- it calls script function unload
List 1.9

Reevaluating Script File

As we said before, some time is interesting to reevaluate script file for developing purposes. Is faster reevaluate in the fly than reexecute the engine, pressing simple key. We modify the code 1.8 presented as follows:

C++
do{
    // clear screen...
    render->clear(0,0,0);

    // update keyboard events...
    input->update();


    // if press F5 then reload file...
    if(T_F5) {
       try{
           (*unload)();

           //Now, the state is the same as we had before eval the script...
           zetscript->evalFile(argv[1])){

            // Recreate script functions...
            init=bindScriptfunction<void ()>("init");
            update=bind_function<void ()>("update");
            unload=bind_function<void ()>("unload");

            // Call init function...
            (*init)();
       }catch(std::exception & ex){
          fprintf(stderr,"%s",ex.what());
       }
    }

   try{
     (*update)();
   }catch(std::exception & ex){
     fprintf(stderr,"%s",ex.what());
   }

    // update screen...
    render->update();
}while(!T_ESC);
List 1.10

The code 1.10 implements a refresh behaviour like it was as browser, i.e., when the user presses the F5 key, the script is reevaluated. The binding functions ini, update and unload must be recreated due to changes in memory after reevaluating the file.

Invader Game

We have presented the implementation of our C++ game engine to process a implemented games from script files. Now, we will show how to implement an Invader game using the game engine.

First of all, as we have mentioned before, the engine expects to implement three functions in the script side:

  • init
  • update
  • unload

These three functions are declared in the file as shown in the following code:

JavaScript
function init(){

}

function update(){

}

function unload(){

}
List 2.1

Loading Images

The engine supports two types of images: binary and bitmap images.

To load bitmap, we can use CImage::load so for example Invader game loads title game bitmap called title.bmp. The variable declaration and its loading is done doing the following:

C++
var invader_title;

function init(){

    invader_title=new Image();
    invader_title.load("invader_title.bmp");

}

To load bit based images, we have to use the CImage constructor function CImage::createScquarePixmap we have registered.

If we remember the code from list 1.7, we registered CImage::createSquarePixmap function member called as CImage (the same name as the class),

C++
registerNativeFunctionMember<Image>("Image",static_cast<bool 
 (Image::*)(zetscript::CVectorScriptVariable * )>(&Image::createSquarePixmap));

What it means is that CImage::createSquarePixmap is declared as CImage constructor in script side because the function name exposed in script side is the same as the class name, i.e., CImage. CVectorScriptVariable is the vector type of ZetScript (for further information, visit zetscript.org).

For example, the following code creates CImage passing a vector with binary integers as parameter:

C++
new Image( <-- Image constructor 
  [ // <-- vector variable
     00100000100b
    ,10010001001b
    ,10111111101b
    ,11101110111b
    ,11111111111b
    ,01111111110b
    ,00100000100b
    ,01000000010b
  ]
)
List 2.2

That it represents the following image:

The 1s in binary format paints and 0s doesn't.

Quote:

Note: The game implements more images but to not fill too much these article with code, I keep out them.

Implementing Our Custom Sprite Class

We have seen in list 1.3 the Sprite class. ZetScript has the feature to inherit C++ classes. We define MyClassSprite class that inherits Sprite (from C++):

JavaScript
//Defines CMySprite class. inherits CSprite (from C++)
class MySprite: Sprite{
    constructor(){             // constructor with no parameters
        this.time_life=0;      // tells remaining time to have this sprite living
        this.attack_time=0;    // tells time to do next attack
        this.points=undefined; // tells the value to add in the score 
                               // when sprite is destroyed.
        this.active=false;     // tells if sprite is active or not
        this.color=0xFFFFFF;   // tells sprite color 
    }
    
    constructor(_points,_color){ // constructor with arguments
        this.active=false;
        this.points=_points; 
        this.color=_color;
        this.time_life = 0;
        this.attack_time=0;
    }
    
    // adds image frame...
    addFrame(_img){
        super(_img, this.color); // <-- calls CImage::addFrame (from C++)
    }
    
    // updates sprite
    update(){
        if(this.time_life>0){
            if(this.time_life < currentTime()){
                this.active=false;
            }
        } 
        super(); // it calls CSprite::update (from C++)
    }
};
List 2.3

Sprite Manager

After we have defined our custom MySprite class for script, we are presenting another important class that will manage the logic flow of sprite type. SpriteManager will create and update a set of sprites of the same type.

In invader game, we have the following sprite types:

  • Enemy bullets sprites
  • Hero bullets sprites
  • Enemy sprites

In order to explain better how SpriteManager works, we present a simplified code of SpriteManager class with the important content from the original source:

JavaScript
class SpriteManager{

    // it creates spritemanager with a vector of sprite type defined by max_sprites
    constructor(max_sprites, image,_max_time_life){
        this.x_base=0;      // offset x of sprites
        this.y_base=0;      // offset y of sprites
        this.dx=0;          // dx update basex foreach iteration.
        this.dy=0;          // dy update basey foreach iteration.
        this.next_mov=0;    // tells time to do next move
        
        this.sprite=[];     // vector of sprites
        this.free_index=[]; // vector telling free sprite slots
        this.max_time_life=_max_time_life; // tells time life for each sprite created.
        
        for(var i=0; i < max_sprites; i++){
            var spr=new CMySprite();
            spr.addFrame(image);
            
            this.sprite.add(spr);
            this.free_index.add(i);
        }
    }
        
    // it creates a sprite at start_x, start_y
    create(start_x, start_y, _dx, _dy){
        var index;
        
        if(this.free_index.size()>0)
        {
            // pops the last value and set sprite as active ...
            index= this.free_index.pop();
            
            this.sprite[index].x= start_x;
            this.sprite[index].y=start_y;
            this.sprite[index].dy=_dy;
            this.sprite[index].dx=_dx;
            this.sprite[index].active=true;
            
            if(this.max_time_life>0){
                this.sprite[index].time_life=currentTime()+this.max_time_life;
            }
        }
    }
        
    // check collision of sprite given within sprites (to override)
    checkCollision(spr)
    {
        
    }
    
    // function doAttack (to override)
    doAttack(spr){

    }
        
    // removes sprite at index i
    remove(i){
        this.sprite[i].active=false;
        this.free_index.add(i);
    }
    
    update(){
        
        for(var i=0; i < this.sprite.size(); i++)
        {
            var spr=this.sprite[i];
            
            if(spr.active){

                    // check if sprite collides with SpriteManager::check_collision
                    this.checkCollision(spr); 

                    // updates sprite
                    spr.update(); 

                    // check if sprite attacks with SpriteManager::doAttack
                    this.doAttack(spr); 
                    
                    // if sprites goes outside screen or its lifetime ends, remove it.
                    if(
                            (spr.y<-spr.height || spr.y>render.getHeight()) 
                         || (spr.time_life>0 && spr.time_life<currentTime())
                          || (spr.x<-spr.width   || spr.x> render.getWidth()) 
                            ){
                        this.remove(i);                        
                    }                    
                
                render.drawSprite(this.x_base,this.y_base,spr); // paint always..
            }
        }
    }    
};
List 2.4

With SpriteManager, we can instance enemy bullets, hero bullets and explosion as SpriteManager objects:

JavaScript
const MAX_ENEMY_BULLETS=20;
const MAX_HERO_BULLETS=10;
const MAX_EXPLOSIONS=20;

var enemy_bullet;
var hero_bullet;
var explosion;

function init(){

     ...

    enemy_bullet=new SpriteManager(MAX_ENEMY_BULLETS,image[7],0);
    hero_bullet=new SpriteManager(MAX_ENEMY_BULLETS,image[9],0);
    explosion=new SpriteManager(MAX_EXPLOSIONS,image[8],200);

   ...

}
List 2.5

On the other hand, enemy sprite manager has a custom implementation from SpriteManager as each enemy is a sprite with two frames used for animation. Also, enemy SpriteManager has to implement some functions as doAttack or check_collision.

Next, the following code presents a simplified implementation of EnemyManager from the original source (to see clearly the overrides doAttack and checkcollision):

JavaScript
class EnemyManager:SpriteManager{
    
    var time_mov;
    var y_top;

    function constructor(){

        this.sprite=[];
        this.x_base=20;
        this.y_base=50;
               
        var x=0;
        // creating 15*3 invaders...
        for(var i=0; i < 3; i++){
            
            //var j=0;
            
            for(var j=0; j < 15; j++){
                
                var color=0;
                var frame1=0;
                var frame2=0
                
                if(i == 0){ // invader type 0
                    color = 0x00FF00; // green color
                    frame1=0; // image index 0 as frame1
                    frame2=1; // image index 1 as frame1
                }else if(i==1){ // invader type 1
                    color = 0x00FFFF; // yellow color 
                    frame1=2; // image index 2 as frame1
                    frame2=3; // image index 3 as frame1
                }else if(i == 2){ // invader type 2
                    color = 0xFFFF00; // cyan color
                    frame1=4; // image index 4 as frame1
                    frame2=5; // image index 5 as frame1
                }
            
                // creates sprite and sets parameters
                var spr=new CMySprite(100,color);
                spr.active=true;
                this.sprite.add(spr);
                spr.addFrame(image[frame1]);        
                spr.addFrame(image[frame2]);
                spr.setTimeFrame(500);
                spr.x=x;
                spr.y=18*i;
                spr.attack_time=currentTime()+rand()%BASE_TIME_ATTACK+5000;
            
                x+=16;
            }
        }
    }    
    
    // implement check_collision. It check collision for all hero bullets...
    function checkCollision(spr){
        if(spr.active){
            for(var i=0;i < hero_bullet.sprite.size(); i++){
                
                if(hero_bullet.sprite[i].active){
                    
                    if(checkCollision(this.x_base,this.y_base,spr,hero_bullet.sprite[i])){
                        hero_bullet.remove(i);
                        spr.active=false;
                        this.base_mov-=15;
                        score+=100;
                        if(top_score<score){
                            top_score=score;
                        }
                        explosion.create(spr.x,spr.y,0,0);
                    }
                }
            }
        }
    }
    
    // implements doAttack for enemy sprite.
    function doAttack(spr){
        if(spr.attack_time>0){
            if(spr.attack_time<currentTime()){
                spr.attack_time=currentTime()+BASE_TIME_ATTACK+rand()%5000;
                // it creates enemy bullet at enemy position with 2 as y velocity
                enemy_bullet.create(spr.x,spr.y,0,2);
            }
        }
    }    
};
List 2.6

In list 2.6, we can see that sprite manager implementation for enemies checks collision for each sprite with hero bullet, when hero bullet collides with some enemy sprites, it adds score and creates explosion at the same position where the enemy died.

In doAttack function, it implements if the enemy is ready to do an attack that basically creates a bullet at the same position where the enemy is.

Update Function

The update function is the main update function that engine calls in each iteration and will update all SpriteManager objects instanced in the init function as we can show in the following code:

JavaScript
hero_bullet.update();
explosion.update();
enemy.update();
enemy_bullet.update();

In the same function, we have to know if any enemy bullet collides with hero sprite and then do the respectively actions:

JavaScript
for(var i=0;i < enemy_bullet.sprite.size(); i++){

    if(enemy_bullet.sprite[i].active){
        if(checkCollision(hero,enemy_bullet.sprite[i])){

            enemy_bullet.remove(i);

            // do actions when hero is destroyed.
        }
    }
}

Furthermore, the update function will check whether left or right is pressed through T_LEFT/T_RIGHT variables already registered in the main.cpp. If one of these keys is pressed, the hero will move to left/right respectively. About hero bullets, if the key SPACE is pressed, a hero bullet will be created at hero position as it shows the following code:

JavaScript
if(TR_LEFT){
   hero.x--;
}
                    
if(TR_RIGHT){
   hero.x++;
}
                    
if(T_SPACE){ // creates a bullet hero with -5 as velocity in y
   hero_bullet.create(hero.x, hero.y, 0, -5);
}

Conclusion

In this article, we have seen a complete example of application made in C++ as game engine combined with ZetScript that allowed us to bind C++ functions and variables in script side. I would have liked to go more in depth about how the game engine works but it would go out of the main purposes of this article and maybe it would have been a bit tedious to follow it.

Invader game consists of about 800 lines and it would have could be less if SpriteMannager would have been implemented in the C++ side, but I preferred to show the combination of C++ with ZetScript as mixed as possible to see its benefits.

History

  • 2020-09-XX ZetScript Game Engine 2.0.0: Changes since 2.0
  • 2018-02-26 Port invader demo to online version, thanks to emscripten
  • 2018-02-21 ZetScript Game Engine 1.1.0: Changes since 1.2
  • 2017-11-29 ZetScript Game Engine 1.0.0: First release

License

This article, along with any associated source code and files, is licensed under The MIT License


Written By
Software Developer (Senior)
Spain Spain
I'm a happy person who likes programming. It has been a passion since my nineteen's Smile | :) Smile | :) .

Comments and Discussions

 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA12-Jul-21 2:09
professionalȘtefan-Mihai MOGA12-Jul-21 2:09 
GeneralRe: My vote of 5 Pin
jespa00712-Jul-21 22:26
jespa00712-Jul-21 22:26 
Questionrun it from command line Pin
Southmountain10-Jul-21 9:50
Southmountain10-Jul-21 9:50 
AnswerRe: run it from command line Pin
jespa00711-Jul-21 22:47
jespa00711-Jul-21 22:47 
PraiseUpvote = Space Invaders Pin
Slacker00721-Feb-18 3:04
professionalSlacker00721-Feb-18 3:04 
Thanks. I look forward to reading this later today.

Smile | :)

-- rants are the vehicle of the lazy and uninspired - JSOP 2/2018

GeneralRe: Upvote = Space Invaders Pin
jespa00721-Feb-18 7:53
jespa00721-Feb-18 7:53 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.