By ChatGPT & Benji Asperheim | 2024-12-20Blog Thumbnail

2D Game Engine Using an SDL2 Wrapper in Rust

This article explores how to use Rust and its SDL2 crate to develop a compiled 2D game engine. Simple DirectMedia Layer (SDL2) is a cross-platform development library that provides low-level access to audio, keyboard, mouse, joystick, and graphics hardware via OpenGL and Direct3D. By leveraging SDL2 in Rust, we can create a flexible and customizable game engine that works in conjunction with a high-level wrapper to build 2D GUI applications and video games.

The engine's design allows it to communicate with any high-level wrapper—regardless of the programming language used—as long as it can send the proper Base64-encoded JSON string to the Rust binary. This means developers can implement game logic, event handling, and business rules in their preferred language while relying on the Rust SDL2 binary for efficient rendering of images into surfaces on the window. This article will walk you through the key components of the Rust code and explain how to set up this versatile architecture for your own projects.

You can view the source code (and some high-level wrapper examples) in the github repository.

Install the SDL2 Libraries and Dependencies

Building locally eliminates the complexities associated with cross-compilation, such as setting up cross-compilers, managing different versions of libraries, and ensuring that all dependencies are compatible with the target architecture.

Let's go over how to install the SDL2 libaries so that Rust can include them in the final binary/executable when you run cargo build.

Indirectly Install SDL2 Using PyGame

Merely installing PyGame is an indirect way to install SDL2 libraries (and other necessary dependencies) on your machine:

pip3 install pygame

This approach abstracts away the complexities of installing SDL2 and makes it user-friendly. Users can focus on writing their game code without having to manage external dependencies manually.

Install SDL2 on a Debian-based Distro

To install SDL2 on a distro like Ubuntu you can do the following.

First, update the apt-get repository:

sudo apt-get update

Then install the necessary -dev libraries and dependencies for SDL2:

sudo apt-get install libxmu-dev libxmu-headers \
   freeglut3-dev libxext-dev libxi-dev libsdl2-image-dev

Install SDL2 on Red Hat

You can use dnf to install SDL2 on Red Hat distros (like CentOS or Fedora).

First, update the repository:

sudo dnf update

Then install the following -devel packages:

sudo dnf install libXmu-devel \
   freeglut-devel \
   libXext-devel \
   libXi-devel \
   SDL2_image-devel

Install SDL2 on macOS

You can just use Homebrew to install SDL2 on macOS:

# Update Homebrew
brew update

# Install SDL2 and SDL2_image
brew install sdl2 sdl2_image sdl2_font sdl2_ttf

Compile SDL2 from Source

If you decide you want to compile SDL2 from source, then you will need to use the make and cmake commands, as well something like wget or cURL to fetch the TAR archive for your target operating system (you can also download pre-compiled macOS DMGs and Windows' zip executables).

Install cmake Build Tools and Dependencies

On macOS you will need to install the following (with Homebrew), as well as have the latest version of Xcode (and its Command Line Tools) installed:

brew install cmake freeglut libxmu

On a Debian-based distro use apt-get to install the necessary tools and dependencies:

sudo apt-get update && sudo apt-get install cmake wget build-essential \
   apt-transport-https ca-certificates gnupg

On Red Hat Linux (like CentOS or Fedora) you will need to run the following dnf commands:

sudo dnf update && sudo dnf install cmake wget \
   gcc gcc-c++ make

This setup will ensure you have all the necessary tools and libraries to compile SDL2 successfully on UNIX-like systems. After installing these packages, you should be ready to compile SDL2 from source.

Other Helpful SDL2 Packages

Here's a list of other helpful development tools and libraries you may want to install:

  • libx11-dev, libxext-dev, libxmu-dev, libxi-dev, libxrandr-dev, libxinerama-dev, libxcursor-dev: Development packages for the X Window System, which SDL may depend on for graphical features.
  • libsdl2-image-dev: Development package for SDL2_image, necessary if you're planning to use it.
  • libfreetype6-dev: Required for font rendering.
  • libasound2-dev: For ALSA sound support on Linux.
  • libpulse-dev: For audio support using PulseAudio.
  • libvulkan-dev: For Vulkan graphics support if you plan to use it.
  • libgl1-mesa-dev, libglu1-mesa-dev: For OpenGL support.

Compiling SDL2

You can also compile SDL2 from source if you'd rather go that route.

Download and Compile SDL2 from Source

Use the following wget and tar commands to download and extract the tar.gz SDL2 archive:

wget -O SDL2-2.30.8.tar.gz "https://github.com/libsdl-org/SDL/releases/download/release-2.30.8/SDL2-2.30.8.tar.gz"
tar -xvzf SDL2-2.30.8.tar.gz
cd SDL2-2.30.8

Once you're inside of the extracted directory, you can follow the Github instructions and run this cmake command in UNIX-like (macOS and Linux) systems:

cmake -S . -B build && cmake --build build && cmake --install build

Conversely, ChatGPT recommends doing the following instead:

1. Create SDL2 Build Directory:

cd SDL2-2.30.8 && mkdir build && cd build

2. Configure and Compile SDL2:

cmake ..
make
sudo make install

3. Refresh Library Cache:

sudo ldconfig

NOTE: See the libsdl-org/SDL Github install instructions and this install page for more details.

Verify the SDL2 Installation

You can check for the presence of SDL2 library files in standard library directories:

# For x86_64 Linux systems
ls /usr/lib/x86_64-linux-gnu | grep SDL2

# For ARM64 Linux systems (like the Raspberry Pi)
ls /usr/lib/aarch64-linux-gnu | grep SDL2

You can also grep for the SDL2 files in the /usr/local/lib directory:

ls /usr/local/lib | grep SDL2

The above command should output something like this:

libSDL2-2.0.so
libSDL2-2.0.so.0
libSDL2-2.0.so.0.3000.8
libSDL2.a
libSDL2main.a
libSDL2.so
libSDL2_test.a

For macOS Homebrew installations do the following:

brew list | grep sdl2

Install Rust

We're now ready to install Rust, and write some code that uses the sdl2 Cargo crate!

Use this cURL command to install Rust:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

You should see something like the following prompting you to make a selection:

info: downloading installer

Welcome to Rust!

This will download and install the official compiler for the Rust
programming language, and its package manager, Cargo.

Rustup metadata and toolchains will be installed into the Rustup
home directory...

1) Proceed with standard installation (default - just press enter)
2) Customize installation
3) Cancel installation
>

It should have appended . "$HOME/.cargo/env" to your bash profile. Once you find the correct file, just run the source command on it, or restart your terminal session:

cat ~/.profile | grep cargo
. "$HOME/.cargo/env"
source ~/.profile

If Rust was installed properly, then you should be able to get the Rust compiler version using the rustc --version command:

rustc --version
rustc 1.81.0 (eeb90cda1 2024-09-04)

Rust Game Engine

We're now ready to create a new Rust priject and write some code!

Build Rust Project with Cargo

Use the cargo new command to create a new Rust project:

cargo new rust-sdl2

NOTE: According to Rust lang, the name for a project (or "crate") has unclear naming conventions, with some using kebab-case, and others using snake_case.

Change into the rust-sdl2 directory to get started (cd rust-sdl2). You should now open the project using your preferred IDE or text editor.

Rust TOML File

Rust should have generated a Cargo.toml for you, but you'll need to modify it to include the sdl2 crate, as well as serde and a few others:

[package]
name = "rust-sdl2"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
base64 = "0.21.0"
clap = { version = "4.3.10", features = ["derive"] }
sdl2 = { version = "0.37.0", features = ["image"] }

This will provides bindings to SDL2 for multimedia handling, with support for image loading.

Rust File Example for SDL2

You should also have a src directory generated, and there should be a main.rs file in there with something like the following:

fn main() {
    println!("Hello, world!");
}

Erase the "Hello, world!" boilerplate code and replace it with the following use statements at the top of your file. These imports bring in various standard library modules and external crates that you'll need for collections, I/O operations, threading, time management, serialization, base64 encoding/decoding, and SDL2 functionalities:

use std::collections::HashMap;
use std::io::{self, BufRead, Write};
use std::sync::mpsc::{self, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};

use serde::{Deserialize, Serialize};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;

use sdl2::event::Event;
use sdl2::pixels::Color;
use sdl2::rect::Rect;
use sdl2::render::{Canvas, Texture, TextureCreator};
use sdl2::video::{Window, WindowContext};
use sdl2::image::{self, InitFlag, LoadTexture, ImageRWops};

Rust Constants and Structs

Define constants and data structures that will be used throughout the program:

const DEFAULT_TITLE: &str = "Learn Programming 2D Game Engine";
const DEFAULT_ICON: &str = "images/learn-programming-logo-128px.png";

struct TextureManager<'a> {
   textures: HashMap>,
   texture_creator: &'a TextureCreator,
}

impl<'a> TextureManager<'a> {
   fn new(texture_creator: &'a TextureCreator) -> Self {
      Self {
         textures: HashMap::new(),
         texture_creator,
      }
   }

   fn load_texture(&mut self, path: &str) -> Result<&Texture<'a>, String> {
      // println!("Attempting to load texture: {}", path);  // Debugging line
      if !self.textures.contains_key(path) {
         let texture = self.texture_creator.load_texture(path).map_err(|e| {
               eprintln!("Error loading texture '{}': {}", path, e);
               e.to_string()
         })?;
         self.textures.insert(path.to_string(), texture);
      }
      Ok(self.textures.get(path).unwrap())
   }
}

The TextureManager struct handles loading and caching textures to avoid reloading the same image multiple times. The above code also implements methods for the TextureManager.

Now define data structures for window configuration, sprite configuration, and game state:

#[derive(Deserialize, Serialize, Clone)]
struct WindowConfig {
   width: u32,
   height: u32,
   background: String,
   #[serde(default = "default_title")]
   title: String,
   #[serde(default = "default_icon_path")]
   icon_path: String,
}

fn default_title() -> String {
   DEFAULT_TITLE.to_string()
}

fn default_icon_path() -> String {
   DEFAULT_ICON.to_string()
}

#[derive(Deserialize, Serialize, Clone)]
struct SpriteSize {
   width: u32,
   height: u32,
}

#[derive(Deserialize, Serialize, Clone)]
struct SpriteConfig {
   id: String,
   images: Vec,
   location: Point,
   size: SpriteSize,
   #[serde(default = "default_frame_delay")]
   frame_delay: u64, // in milliseconds
   #[serde(skip)]
   current_frame: usize,
   #[serde(skip)]
   last_update: u128, // Using u128 to store milliseconds since UNIX epoch
}

fn default_frame_delay() -> u64 {
   100 // Default to 100ms
}

#[derive(Deserialize, Serialize, Clone)]
struct Point {
   x: i32,
   y: i32,
}

#[derive(Deserialize, Serialize, Clone)]
struct GameState {
   window: WindowConfig,
   sprites: Vec,
   fps: Option,
}

impl GameState {
   fn fps(&self) -> u64 {
      self.fps.unwrap_or(60)
   }
}

Icons and Images

Next, make sure there's also an images directory at the root of the Rust project. Use this code set a default icon from an image in that directory:

// Default icon
const ICON: &[u8] = include_bytes!("../images/learn-programming-logo-128px.png");
fn set_game_icon(canvas: &mut Canvas, file_path: &str) -> Result<(), String> {
   let icon_surface = sdl2::rwops::RWops::from_file(file_path, "r")
      .and_then(|rwops| rwops.load())
      .or_else(|e| {
         eprintln!("Error loading icon from file '{}': {}", file_path, e);
         // Fallback to embedded bytes if file loading fails
         sdl2::rwops::RWops::from_bytes(ICON)
               .and_then(|rwops| rwops.load())  // Here, `load()` is used, ensure `ImageRWops` is imported
               .map_err(|e| {
                  eprintln!("Error loading embedded icon: {}", e);
                  e.to_string()
               })
      })?;

   // Set the icon for the window
   canvas.window_mut().set_icon(icon_surface);
   Ok(())
}

Explanation

  • The include_bytes! macro embeds the default icon image into the binary as bytes.
  • The set_game_icon function attempts to load an icon from the specified file path.
  • If it fails, it falls back to the embedded default icon.
  • Finally, the set_icon call sets the icon for the SDL2 window.

Parse JSON Game State Function

Define a function to parse the game state received as a base64-encoded JSON string:

fn parse_game_state(encoded_data: &str) -> Result {
   let decoded = STANDARD.decode(encoded_data).expect("Failed to decode base64");
   let json_str = String::from_utf8(decoded).expect("Invalid UTF-8 sequence");
   serde_json::from_str::(&json_str)
}

Explanation

The above code will:

  • Decode the base64-encoded string into bytes.
  • Convert the bytes into a UTF-8 string.
  • Deserialize the JSON string into a GameState object using serde_json.

Rust SDL2 Main Function

The main() function initializes SDL2 and sets up the game window and rendering canvas:

fn main() -> Result<(), String> {
   // Initialize SDL2
   let sdl_context = sdl2::init()?;
   let video_subsystem = sdl_context.video()?;

   // Create a window with a default title and size
   let window = video_subsystem
      .window(DEFAULT_TITLE, 800, 600)
      .position_centered()
      .build()
      .map_err(|e| e.to_string())?;

   // Create a canvas (renderer)
   let mut canvas: Canvas = window
      .into_canvas()
      .accelerated()
      .present_vsync()
      .build()
      .map_err(|e| e.to_string())?;

   // Initialize SDL2_image
   let _image_context = image::init(InitFlag::PNG | InitFlag::JPG)?;

   // Initialize the texture manager
   let texture_creator = canvas.texture_creator();
   let mut texture_manager = TextureManager::new(&texture_creator);

   // Set up event handling
   let mut event_pump = sdl_context.event_pump()?;

   // Set up channel for non-blocking input
   let (tx, rx): (mpsc::Sender, mpsc::Receiver) = mpsc::channel();

   // Spawn a thread to read from stdin
   thread::spawn(move || {
      let stdin = io::stdin();
      for line in stdin.lock().lines() {
         if let Ok(input) = line {
               if tx.send(input).is_err() {
                  break;
               }
         }
      }
   });

   let mut game_state: Option = None;
   let mut frame_duration = Duration::from_millis(16); // Default to ~60 FPS
   let mut last_frame_time = Instant::now();
   let mut icon_set = false;
   // ...

Initialization

The start of the main() function will:

  • Initialize SDL2 and the video subsystem.
  • Create a window with a default title and size.
  • Create a rendering canvas from the window.
  • Initialize the SDL2_image library to support image formats.
  • Set up the TextureManager to handle textures.
  • Prepare the event pump for handling input events.
  • Set up an MPSC (multi-producer, single-consumer) channel for non-blocking communication.
  • Spawn a thread to read lines from stdin and send them through the channel.

Variables

  • game_state: Holds the current game state, initially None.
  • frame_duration: Determines how long each frame should take based on the desired FPS.
  • last_frame_time: Used to calculate delta time for animations.
  • icon_set: A flag to ensure the window icon is set only once.

Game Loop Inside Main

Now we can begin the main game loop with the 'running label:

   // ...

   'running: loop {
      // Non-blocking receive
      match rx.try_recv() {
         Ok(input) => {
               match parse_game_state(&input) {
                  Ok(mut new_state) => {
                     // Resize window if necessary
                     let (current_width, current_height) = canvas.output_size()?;
                     if new_state.window.width != current_width || new_state.window.height != current_height {
                           canvas
                              .window_mut()
                              .set_size(new_state.window.width, new_state.window.height)
                              .map_err(|e| e.to_string())?;
                     }

                     // Update frame duration based on FPS
                     frame_duration = Duration::from_millis(1000 / new_state.fps());

                     if let Some(existing_state) = &mut game_state {
                           // Update window config
                           existing_state.window = new_state.window;

                           // Take ownership of existing_state.sprites and create a HashMap
                           let mut existing_sprites_map: HashMap = existing_state.sprites
                              .drain(..)
                              .map(|sprite| (sprite.id.clone(), sprite))
                              .collect();

                           // Prepare a new vector for updated sprites
                           let mut updated_sprites = Vec::new();

                           // Process each sprite in new_state.sprites
                           for mut new_sprite in new_state.sprites {
                              if let Some(mut existing_sprite) = existing_sprites_map.remove(&new_sprite.id) {
                                 // Update fields while preserving animation state
                                 existing_sprite.images = new_sprite.images;
                                 existing_sprite.location = new_sprite.location;
                                 existing_sprite.frame_delay = new_sprite.frame_delay;
                                 updated_sprites.push(existing_sprite);
                              } else {
                                 // New sprite, initialize animation fields
                                 new_sprite.current_frame = 0;
                                 new_sprite.last_update = 0;
                                 updated_sprites.push(new_sprite);
                              }
                           }

                           // Update existing_state.sprites with the updated sprites
                           existing_state.sprites = updated_sprites;

                           // Update the window title
                           canvas.window_mut().set_title(&existing_state.window.title).map_err(|e| e.to_string())?;

                           // Set the icon if not already set
                           if !icon_set {
                              set_game_icon(&mut canvas, &existing_state.window.icon_path)?;
                              icon_set = true; // Update the flag
                           }

                     } else {
                           // No existing game_state, so initialize it and set the title and icon
                           for sprite in &mut new_state.sprites {
                              sprite.current_frame = 0;
                              sprite.last_update = 0;
                           }
                           game_state = Some(new_state);

                           // Set the window title and icon for the first time
                           canvas.window_mut().set_title(&game_state.as_ref().unwrap().window.title).map_err(|e| e.to_string())?;

                           if !icon_set {
                              set_game_icon(&mut canvas, &game_state.as_ref().unwrap().window.icon_path)?;
                              icon_set = true; // Update the flag
                           }
                     }
                  }
                  Err(e) => {
                     eprintln!("Failed to parse game state: {}", e);
                  }
            }
         }
         Err(TryRecvError::Empty) => {}
         Err(TryRecvError::Disconnected) => break 'running,
      }

In Rust, the apostrophe (') in 'running: loop { ... } is a lifetime annotation, or label, that provides a way to name blocks of code, like loops, which can be useful for control flow, particularly when using statements like break and continue.

Explanation

Receiving Game State Updates

  • Tries to receive new game state data from the channel without blocking.
  • Parses the received base64-encoded string into a GameState object.

Window Adjustments

  • Checks if the window size has changed and updates it if necessary.
  • Updates the frame duration based on the new FPS setting.

Game State Handling

If there's an existing game state, update it:

  • Update the window configuration.
  • Preserve animation states of existing sprites.
  • Update sprite properties and manage new or removed sprites.

If there's no existing game state, initialize it:

  • Set initial animation states for sprites.
  • Set the window title and icon.

Game Engine Event Handler

Handle SDL2 events, such as quitting, key presses, and mouse clicks, using event_pump.poll_iter():

      // Handle events
      for event in event_pump.poll_iter() {
         match event {
               Event::Quit { .. } => {
                  println!("Event: Quit");
                  io::stdout().flush().unwrap();
                  break 'running;
               }
               Event::KeyDown { keycode: Some(keycode), .. } => {
                  println!("Event: KeyDown {:?}", keycode);
                  io::stdout().flush().unwrap();
               }
               Event::KeyUp { keycode: Some(keycode), .. } => {
                  println!("Event: KeyUp {:?}", keycode);
                  io::stdout().flush().unwrap();
               }
               Event::MouseButtonDown { x, y, mouse_btn, .. } => {
                  println!("Event: MouseButtonDown {:?} at ({}, {})", mouse_btn, x, y);
                  io::stdout().flush().unwrap();
               }
               Event::MouseButtonUp { x, y, mouse_btn, .. } => {
                  println!("Event: MouseButtonUp {:?} at ({}, {})", mouse_btn, x, y);
                  io::stdout().flush().unwrap();
               }
               _ => {}
         }
      }

Explanation

The above event handler loops through all pending events, and it handles different types of events, and prints them to stdout, which can be read by the high-level wrapper.

Events handled:

It catches and handles various Event:: methods in the loop:

  • Quit: When the window is closed.
  • KeyDown and KeyUp: When keys are pressed or released.
  • MouseButtonDown and MouseButtonUp: When mouse buttons are pressed or released.

Finally, it flushes stdout to ensure the output is immediately available to the high-level wrapper using this window instance.

End of Main Function

Finally, we can loop over the sprites retrieved from the game state to determine where to draw their respective surfaces, and then call canvas.present() to update the screen:

      // Calculate delta time
      let now = Instant::now();
      let delta_time = now.duration_since(last_frame_time).as_millis();
      last_frame_time = now;

      // Clear the screen
      canvas.set_draw_color(Color::RGB(0, 0, 0));
      canvas.clear();

      if let Some(ref mut state) = game_state {
         // Render background
         let bg_texture = texture_manager.load_texture(&state.window.background)?;
         canvas.copy(&bg_texture, None, None)?;

         // Render sprites
         for sprite_config in &mut state.sprites {
               if sprite_config.images.is_empty() {
                  continue; // Skip if there are no images
               }

               // Update animation frame
               sprite_config.frame_delay = sprite_config.frame_delay.max(1); // Ensure frame_delay is at least 1ms
               sprite_config.last_update += delta_time;

               if sprite_config.last_update >= sprite_config.frame_delay as u128 {
                  sprite_config.current_frame = (sprite_config.current_frame + 1) % sprite_config.images.len();
                  sprite_config.last_update = 0;
               }

               let texture = texture_manager.load_texture(&sprite_config.images[sprite_config.current_frame])?;
               let position = Rect::new(
                  sprite_config.location.x,
                  sprite_config.location.y,
                  sprite_config.size.width,
                  sprite_config.size.height
               );
               canvas.copy(&texture, None, Some(position))?;
         }
      }

      // Update the screen
      canvas.present();

      // Frame rate control
      ::std::thread::sleep(frame_duration);
   }

   Ok(())
}

Explanation

Delta Time Calculation:

  • Calculates the time elapsed since the last frame to manage animations accurately.

Rendering:

  • Clears the screen with a black color.
  • Renders the background image.

Iterates over each sprite in the game state:

  • Skips sprites with no images.
  • Updates the animation frame based on the frame delay and delta time.
  • Loads the current frame's texture.
  • Draws the sprite at its specified location and size.
  • Presenting the Frame:
  • Calls canvas.present() to update the window with the new frame.

Frame Rate Control:

  • Sleeps for the remaining time in the frame duration to maintain consistent FPS.
  • Finally, the main function returns Ok(()), signaling successful execution.

Complete Code

Here's the main.rs code in its entirety:

use std::collections::HashMap;
use std::io::{self, BufRead, Write};
use std::sync::mpsc::{self, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};

use serde::{Deserialize, Serialize};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;

use sdl2::event::Event;
use sdl2::pixels::Color;
use sdl2::rect::Rect;
use sdl2::render::{Canvas, Texture, TextureCreator};
use sdl2::video::{Window, WindowContext};
use sdl2::image::{self, InitFlag, LoadTexture, ImageRWops};

const DEFAULT_TITLE: &str = "Learn Programming 2D Game Engine";
const DEFAULT_ICON: &str = "images/learn-programming-logo-128px.png";

struct TextureManager<'a> {
   textures: HashMap>,
   texture_creator: &'a TextureCreator,
}

impl<'a> TextureManager<'a> {
   fn new(texture_creator: &'a TextureCreator) -> Self {
      Self {
         textures: HashMap::new(),
         texture_creator,
      }
   }

   fn load_texture(&mut self, path: &str) -> Result<&Texture<'a>, String> {
      // println!("Attempting to load texture: {}", path);  // Debugging line
      if !self.textures.contains_key(path) {
         let texture = self.texture_creator.load_texture(path).map_err(|e| {
               eprintln!("Error loading texture '{}': {}", path, e);
               e.to_string()
         })?;
         self.textures.insert(path.to_string(), texture);
      }
      Ok(self.textures.get(path).unwrap())
   }
}

#[derive(Deserialize, Serialize, Clone)]
struct WindowConfig {
   width: u32,
   height: u32,
   background: String,
   #[serde(default = "default_title")]
   title: String,
   #[serde(default = "default_icon_path")]
   icon_path: String,
}

fn default_title() -> String {
   DEFAULT_TITLE.to_string()
}

fn default_icon_path() -> String {
   DEFAULT_ICON.to_string()
}

#[derive(Deserialize, Serialize, Clone)]
struct SpriteSize {
   width: u32,
   height: u32,
}

#[derive(Deserialize, Serialize, Clone)]
struct SpriteConfig {
   id: String,
   images: Vec,
   location: Point,
   size: SpriteSize,
   #[serde(default = "default_frame_delay")]
   frame_delay: u64, // in milliseconds
   #[serde(skip)]
   current_frame: usize,
   #[serde(skip)]
   last_update: u128, // Using u128 to store milliseconds since UNIX epoch
}

fn default_frame_delay() -> u64 {
   100 // Default to 100ms
}

#[derive(Deserialize, Serialize, Clone)]
struct Point {
   x: i32,
   y: i32,
}

#[derive(Deserialize, Serialize, Clone)]
struct GameState {
   window: WindowConfig,
   sprites: Vec,
   fps: Option,
}

impl GameState {
   fn fps(&self) -> u64 {
      self.fps.unwrap_or(60)
   }
}

// Default icon
const ICON: &[u8] = include_bytes!("../images/learn-programming-logo-128px.png");
fn set_game_icon(canvas: &mut Canvas, file_path: &str) -> Result<(), String> {
   let icon_surface = sdl2::rwops::RWops::from_file(file_path, "r")
      .and_then(|rwops| rwops.load())
      .or_else(|e| {
         eprintln!("Error loading icon from file '{}': {}", file_path, e);
         // Fallback to embedded bytes if file loading fails
         sdl2::rwops::RWops::from_bytes(ICON)
               .and_then(|rwops| rwops.load())  // Here, `load()` is used, ensure `ImageRWops` is imported
               .map_err(|e| {
                  eprintln!("Error loading embedded icon: {}", e);
                  e.to_string()
               })
      })?;

   // Set the icon for the window
   canvas.window_mut().set_icon(icon_surface);
   Ok(())
}

fn parse_game_state(encoded_data: &str) -> Result {
   let decoded = STANDARD.decode(encoded_data).expect("Failed to decode base64");
   let json_str = String::from_utf8(decoded).expect("Invalid UTF-8 sequence");
   serde_json::from_str::(&json_str)
}

fn main() -> Result<(), String> {
   // Initialize SDL2
   let sdl_context = sdl2::init()?;
   let video_subsystem = sdl_context.video()?;

   // Create a window with a default title and size
   let window = video_subsystem
      .window(DEFAULT_TITLE, 800, 600)
      .position_centered()
      .build()
      .map_err(|e| e.to_string())?;

   // Create a canvas (renderer)
   let mut canvas: Canvas = window
      .into_canvas()
      .accelerated()
      .present_vsync()
      .build()
      .map_err(|e| e.to_string())?;

   // Initialize SDL2_image
   let _image_context = image::init(InitFlag::PNG | InitFlag::JPG)?;

   // Initialize the texture manager
   let texture_creator = canvas.texture_creator();
   let mut texture_manager = TextureManager::new(&texture_creator);

   // Set up event handling
   let mut event_pump = sdl_context.event_pump()?;

   // Set up channel for non-blocking input
   let (tx, rx): (mpsc::Sender, mpsc::Receiver) = mpsc::channel();

   // Spawn a thread to read from stdin
   thread::spawn(move || {
      let stdin = io::stdin();
      for line in stdin.lock().lines() {
         if let Ok(input) = line {
               if tx.send(input).is_err() {
                  break;
               }
         }
      }
   });

   let mut game_state: Option = None;
   let mut frame_duration = Duration::from_millis(16); // Default to ~60 FPS
   let mut last_frame_time = Instant::now();
   let mut icon_set = false;

   'running: loop {
      // Non-blocking receive
      match rx.try_recv() {
         Ok(input) => {
               match parse_game_state(&input) {
                  Ok(mut new_state) => {
                     // Resize window if necessary
                     let (current_width, current_height) = canvas.output_size()?;
                     if new_state.window.width != current_width || new_state.window.height != current_height {
                           canvas
                              .window_mut()
                              .set_size(new_state.window.width, new_state.window.height)
                              .map_err(|e| e.to_string())?;
                     }

                     // Update frame duration based on FPS
                     frame_duration = Duration::from_millis(1000 / new_state.fps());

                     if let Some(existing_state) = &mut game_state {
                           // Update window config
                           existing_state.window = new_state.window;

                           // Take ownership of existing_state.sprites and create a HashMap
                           let mut existing_sprites_map: HashMap = existing_state.sprites
                              .drain(..)
                              .map(|sprite| (sprite.id.clone(), sprite))
                              .collect();

                           // Prepare a new vector for updated sprites
                           let mut updated_sprites = Vec::new();

                           // Process each sprite in new_state.sprites
                           for mut new_sprite in new_state.sprites {
                              if let Some(mut existing_sprite) = existing_sprites_map.remove(&new_sprite.id) {
                                 // Update fields while preserving animation state
                                 existing_sprite.images = new_sprite.images;
                                 existing_sprite.location = new_sprite.location;
                                 existing_sprite.frame_delay = new_sprite.frame_delay;
                                 updated_sprites.push(existing_sprite);
                              } else {
                                 // New sprite, initialize animation fields
                                 new_sprite.current_frame = 0;
                                 new_sprite.last_update = 0;
                                 updated_sprites.push(new_sprite);
                              }
                           }

                           // Update existing_state.sprites with the updated sprites
                           existing_state.sprites = updated_sprites;

                           // Update the window title
                           canvas.window_mut().set_title(&existing_state.window.title).map_err(|e| e.to_string())?;

                           // Set the icon if not already set
                           if !icon_set {
                              set_game_icon(&mut canvas, &existing_state.window.icon_path)?;
                              icon_set = true; // Update the flag
                           }

                     } else {
                           // No existing game_state, so initialize it and set the title and icon
                           for sprite in &mut new_state.sprites {
                              sprite.current_frame = 0;
                              sprite.last_update = 0;
                           }
                           game_state = Some(new_state);

                           // Set the window title and icon for the first time
                           canvas.window_mut().set_title(&game_state.as_ref().unwrap().window.title).map_err(|e| e.to_string())?;

                           if !icon_set {
                              set_game_icon(&mut canvas, &game_state.as_ref().unwrap().window.icon_path)?;
                              icon_set = true; // Update the flag
                           }
                     }
                  }
                  Err(e) => {
                     eprintln!("Failed to parse game state: {}", e);
                  }
            }
         }
         Err(TryRecvError::Empty) => {}
         Err(TryRecvError::Disconnected) => break 'running,
      }

      // Handle events
      for event in event_pump.poll_iter() {
         match event {
               Event::Quit { .. } => {
                  println!("Event: Quit");
                  io::stdout().flush().unwrap();
                  break 'running;
               }
               Event::KeyDown { keycode: Some(keycode), .. } => {
                  println!("Event: KeyDown {:?}", keycode);
                  io::stdout().flush().unwrap();
               }
               Event::KeyUp { keycode: Some(keycode), .. } => {
                  println!("Event: KeyUp {:?}", keycode);
                  io::stdout().flush().unwrap();
               }
               Event::MouseButtonDown { x, y, mouse_btn, .. } => {
                  println!("Event: MouseButtonDown {:?} at ({}, {})", mouse_btn, x, y);
                  io::stdout().flush().unwrap();
               }
               Event::MouseButtonUp { x, y, mouse_btn, .. } => {
                  println!("Event: MouseButtonUp {:?} at ({}, {})", mouse_btn, x, y);
                  io::stdout().flush().unwrap();
               }
               _ => {}
         }
      }

      // Calculate delta time
      let now = Instant::now();
      let delta_time = now.duration_since(last_frame_time).as_millis();
      last_frame_time = now;

      // Clear the screen
      canvas.set_draw_color(Color::RGB(0, 0, 0));
      canvas.clear();

      if let Some(ref mut state) = game_state {
         // Render background
         let bg_texture = texture_manager.load_texture(&state.window.background)?;
         canvas.copy(&bg_texture, None, None)?;

         // Render sprites
         for sprite_config in &mut state.sprites {
               if sprite_config.images.is_empty() {
                  continue; // Skip if there are no images
               }

               // Update animation frame
               sprite_config.frame_delay = sprite_config.frame_delay.max(1); // Ensure frame_delay is at least 1ms
               sprite_config.last_update += delta_time;

               if sprite_config.last_update >= sprite_config.frame_delay as u128 {
                  sprite_config.current_frame = (sprite_config.current_frame + 1) % sprite_config.images.len();
                  sprite_config.last_update = 0;
               }

               let texture = texture_manager.load_texture(&sprite_config.images[sprite_config.current_frame])?;
               let position = Rect::new(
                  sprite_config.location.x,
                  sprite_config.location.y,
                  sprite_config.size.width,
                  sprite_config.size.height
               );
               canvas.copy(&texture, None, Some(position))?;
         }
      }

      // Update the screen
      canvas.present();

      // Frame rate control
      ::std::thread::sleep(frame_duration);
   }

   Ok(())
}

Conclusion

This basic 2D game engine, built with Rust and SDL2, offers a flexible and customizable foundation for developing 2D games and GUI applications. Its design allows seamless integration with any high-level wrapper, regardless of the programming language used. By communicating through a Base64-encoded JSON string piped to the Rust binary, developers can leverage the engine's rendering capabilities while handling game logic, events, and business rules in the language of their choice.

This architecture empowers game developers to utilize familiar or preferred programming languages for high-level logic without sacrificing performance in rendering graphics. The Rust SDL2 binary efficiently processes images into surfaces and renders them on the window as directed by the high-level wrapper. This separation of concerns not only simplifies the development process but also makes the engine accessible to a broader range of developers, fostering creativity and innovation in 2D game development.

Result

I tested it with some high-level wrappers, written in Python and NodeJS, in order to do some benchmark and performance comparisons against my own game engine, and this Rust-derived game engine actually performed about 10-20% better than PyGame, although further testing is needed.

Discover expert insights and tutorials on adaptive software development, Python, DevOps, creating website builders, and more at Learn Programming. Elevate your coding skills today!