Poodg

The game
Here it is! You can mouse over, use your scroll to move and your left click to shoot out a hook. The goal of the game is to hook as many creeps as you can.
Motivation
I am a huge Dota 2 player. I’m not a pro, but I enjoy the game and go to basically any tournament in Europe. Lo and behold, the biggest tournament of them all, The Internationals, this year was going to be held in Copenhagen.
For the first time in 11 years I’d be able to watch the world championship in person without shelling out a kidney for flight tickets.
I decided that this would be a great opportunity to create a project that I will be able to have as a memorabilia, but would also be a pretty cool icebreaker to help me socialize in an awkward crowd of introverted gamers.
I had a little over a week to make something. I knew I had to it all the way.
I had a good motivation and a deadline. I just needed a plan.
So of course I decided I wanted to make a custom game console.
Preparations
Hardware
Electronics were mostly decided by the fact I had a dead project lying in my drawer. It was a breadboard with a monochromatic 128x64 OLED display, single rotary encoder, Raspberry Pi Pico and a picoprobe.
actual program originally running on this board was a rework of another project, but this is the only picture I have before soldering it all in
That meant I had staggering 264kB of RAM and 2mB of FLASH, paired with 2 ARM cores clocked at 133MHz.
For those not coming from an embedded background, it’s actually pretty decent.
Not that I’d use two cores anyway. I am . I will not optimize ahead.
Software
I decided to go with Rust. Mainly because of the fact the breadboard already had a set up repo from when I’ve played around with it.
It uses embedded-graphics
to display an animation. I really liked working with the crate, especially because of embedded-graphics-simulator
and embedded-graphics-web-simulator
.
It’s a set of tools for simulating an embedded display. Using them in theory I could write the same code for both embedded devices and a computer, not having to constantly flash a board and eliminating any possible hardware issues from the equation.
Rust also has a very neat macro system which will let me hack together three different architectures, relatively painlessly.
#[cfg(not(any(target_os = "none", target_os = "unknown")))]
fn main() -> Result<(), core
convert
Infallible> {
use native
native_main;
native_main()
}
#[cfg(target_arch = "wasm32")]
fn main() {
use wasm
wasm_main;
wasm_main()
}
#[cfg(target_os = "none")]
#[entry]
fn main() -> ! {
use embed
embed_main;
embed_main()
}
Concept
Since I was going to a Dota event, I knew I had to make a Dota-themed game.
Obvious choice was a game starring the de-facto mascot of the game: Pudge. A disgusting, rotting heap of flesh with a hook. Very personable.
I sat down with Aseprite and drew a simple animation of what I wanted the game to look like.
It was basically all the dev art I needed. While I had the program open, I drew up a splash screen as well and decided not to come back until I had an already finished product.
I had a bunch of other ideas, like letting the player control the hook mid-flight and give additional points for patterns you’d make with your line. There were only about 11 days before I had to leave for the event, so those were shelved as “nice to have”.
The code
I had a plan, basic requirements and a breadboard. I ordered some additional electronics that ultimately wouldn’t make it and got to the most enjoyable part for me. The coding.
I decided to stick to most comfortable things first and slowly work my way up, so I started out with native.
Architecture
I figured isolating the game logic from the system layer as much as possible was the way to go. Everything game-related was wrapped in a Game
struct, which has three main public methods: draw(&mut display)
, control(action)
and process(time)
.
The biggest problem would be the display. Every type of the display would need to be handled differently.
In the beginning, I’ve tried being ‘smart’ and using Rc<Box<dyn Trait>>
and got stuck in a endless ref into()
loop for a few hours.
was tingling, so I scrapped everything and decided to stick to passing raw values and references.
So I’ll just pass around an enum.
pub enum DisplayEnum {
#[cfg(not(any(target_os = "none", target_os = "unknown")))]
Simulator(SimulatorDisplay<BinaryColor>),
#[cfg(target_arch = "wasm32")]
WebView(WebSimulatorDisplay<BinaryColor>),
#[cfg(target_os = "none")]
Oled(OledDisplay),
Mock(MockDisplay<BinaryColor>),
}
Then I can do something like this:
pub fn clear_display(display: &mut DisplayEnum) {
match *display {
DisplayEnum
Mock(ref mut disp) => disp.clear(BinaryColor
Off).unwrap(),
#[cfg(not(any(target_os = "none", target_os = "unknown")))]
DisplayEnum
Simulator(ref mut disp) => disp.clear(BinaryColor
Off).unwrap(),
#[cfg(target_arch = "wasm32")]
DisplayEnum
WebView(ref mut disp) => disp.clear(BinaryColor
Off).unwrap(),
#[cfg(target_os = "none")]
DisplayEnum
Oled(ref mut disp) => disp.clear(),
}
}
It’s ugly, but it do trick. I’ll refactor it later and 99 other lies we tell ourselves at night.
Graphics
Here lies a great hurdle to a beginner rustacean. Lifetimes.
When creating an image using embedded-graphics
, it will have to have a lifetime assigned. For those not familliar with Rust, lifetimes are little tick prefixed variables defining when an object goes out of bounds, visualized like so: RawImage<'a, BinaryColor>
, where 'a
is a lifetime designation.
Getting past a borrow checker was easier than wrangling lifetimes. I’ve already burned myself using them once and I’ll spare you the gory details. in me would make everything static, so the lifetimes are essentially infinite and nothing has any chance of getting untimely dereferenced and I don’t have to deal with
'a
s and 'b
s everywhere.
pub const CREEP: ImageRaw<BinaryColor> =
ImageRaw
new(include_bytes!("../../assets/RadiantCreep.raw"), 13);
[...]
impl Game {
pub fn draw(&mut self, display: &mut DisplayEnum) {
match self.state {
GameState
Init(_) => {
let splash = Image
new(&SPLASH, Point
zero());
draw_image(display, splash);
}
[...]
I converted the images to raw using good ol’
magick RadiantCreep.png -depth 1 "gray:RadiantCreep.raw"
, slapped them straight into the binary and called it a day.
Going embed
After over a week I was finally finished with drawing, controlling, spawning, collision detection and all the other boring stuff I had to do to have an actual game on my hands. I had 2 days left before I had to leave. It was time to flex all my embed-muscles, long atrophied since uni.
I couldn’t procrastinate any longer. It was time to flick no_std
and get down to the land of unsafe
. Thankfully, I found a repository that was doing almost the exact same thing, down to the screen model: game-dont-panic. I don’t think I’d be able to finish the project if I had to reinvent some of the wheel, especially implementing the encoders.
Controls
Here’s how an encoder signal looks like:
It sends data along 2 pins. You derive speed based on frequency of the impulses and direction based on their offset. It’s annoying to implement computing rotation direction and speed. It’s really hard if you are just reading data in a loop.
Thankfully, kpcyrd
of the game-dont-panic
has already implemented it using interrupts, where at the end of each interrupt I’d get a signal of whether the encoder turned and if so, then the direction of it as well.
Interrups are a feature of microcontrollers letting you attach a bit of code to a state change on a pin:
[...]
// in main function
enc_1.set_interrupt_enabled(gpio
Interrupt
EdgeHigh, true);
enc_2.set_interrupt_enabled(gpio
Interrupt
EdgeHigh, true);
enc_1.set_interrupt_enabled(gpio
Interrupt
EdgeLow, true);
enc_2.set_interrupt_enabled(gpio
Interrupt
EdgeLow, true);
[...]
#[interrupt]
fn IO_IRQ_BANK0() {
[...]
if let Some(gpios) = ENCODER_PINS {
let (enc_a, enc_b, enc_btn) = gpios;
enc_a.clear_interrupt(gpio
Interrupt
EdgeLow);
enc_a.clear_interrupt(gpio
Interrupt
EdgeHigh);
enc_b.clear_interrupt(gpio
Interrupt
EdgeLow);
enc_b.clear_interrupt(gpio
Interrupt
EdgeHigh);
// handle enc_a and enc_b state
[...]
Cool, now just pass a reference to a control variable and use it, right?
Sort of. Because it’s an interrupt it can fire multiple times before a frame is rendered. It can also try to write to the address while it’s being read.
Which is why Game.control
is decoupled from Game.process
. I had to use a simple event queue to make sure the player always gets their input processed.
To make sure the program won’t be reading as it’s writing I’ll need a mutex:
static ACTION: Mutex<RefCell<[Option<ControlEnum>; 8]>> = Mutex
new(RefCell
new([None; 8]));
// In interrupt
if new_action.is_some() {
critical_section
with(|cs| {
//Lock the mutex, take its value
let mut actions = ACTION.borrow(cs).take();
for action in actions.iter_mut() {
// Add a new action at the end of the queue
if action.is_none() {
*action = Some(new_action.unwrap());
break;
}
// If the queue is full, drop the action
// I'm targetting 60FPS, at this point it's debouncing
}
// Replace the mutex with new value
ACTION.replace(cs, actions);
//Unlock the mutex
}
});
// In main loop
critical_section
with(|cs| {
// Lock the mutex, take its contents and leave it empty
action_queue = (*ACTION.borrow(cs)).take();
//Unlock the mutex
});
for action in action_queue {
if action.is_none() {
break;
}
game.control(action.unwrap());
}
Looking back at it, I could’ve passed game
to the interrupt and called control
from there, but was already struggling. It hasn’t failed enough yet.
Going wasm
I’ve had my game, I’ve had my device, even a case! It was time to make it playable for other people, because realistically no one is going to download a random executable to run.
I knew no one would really seek it out, but the web simulator was already out there, so might as well, right?
It may be because I’ve never really done WASM before, or it may be because WASM was made to create libraries, not applications or games, but boy was I wrong.
The callback hell
There’s no main function for WASM. You can’t just run a code in a loop, else you’ll get booted by the browser.
You can’t even get a precise time.
It felt like JS a few years ago, but with extra Rust syntax.
For example, here’s a mouse click handler written according to the example input handling:
let click_closure = {
let mouse_click_ref = unsafe { MOUSE.clone().unwrap() };
Closure
wrap(Box
new(move |event: MouseEvent| {
// Only count left-click (button 0)
if event.button() == 0 {
let mut mouse = mouse_click_ref.lock().unwrap();
mouse.click = true;
}
}) as Box<dyn FnMut(_)>)
};
body.add_event_listener_with_callback("click", click_closure.as_ref().unchecked_ref())
.expect("Failed to add event listener");
click_closure.forget();
click_closure.forget()
is necessary, but otherwise it wouldn’t work consistently.
The main thread is just relaunching itself on a set timeout, because otherwise trying to get current datetime would straight up fail.
[main_loop_closure]
// Schedule ourselves for another requestAnimationFrame callback.
set_timeout(graphics_anchor.borrow().as_ref().unwrap(), LOGIC_TIMEOUT);
}) as Box<dyn FnMut()>));
//Start the thread for the first time
set_timeout(graphics_ref.borrow().as_ref().unwrap(), LOGIC_TIMEOUT);
} // main ends here
fn set_timeout(f: &Closure<dyn FnMut()>, timeout_ms: i32) -> i32 {
window()
.set_timeout_with_callback_and_timeout_and_arguments_0(
f.as_ref().unchecked_ref(),
timeout_ms,
)
.expect("should register `setTimeout` OK")
}
Most precise time I could get was in nanoseconds, rather than expected microseconds, so I had to multiply the tickrate and fiddle with character speeds to make it work. It’s awful. I’ll fix it when it stops working.
The case
With hours left before departure, I scrambled to get a case printed. Out of all of this, I’m probably the weakest at CAD. I had to really design around my deficiencies.
I started with drawing the board in software, then sizing it up a bit and making walls around it. Single hole for a USB cable and a bit of plastic with a hole for the encoder. Truly .
The end result
It’s jank, the case doesn’t really fit and the code is a bunch of spaghetti.
Midway through the project I realised there’s no flip
method in embedded-graphics
, so the creeps sometimes suspiciously peer backwards as they walk.
I scrapped an entire power module and battery I had on hand, because it would sometimes just not power on. It needs to be connected to a powerbank.
There’s no controls aside from moving the main character.
It works, I had a ton of fun making it and a ton of fun watching other people enjoy it at the event. I’ve learned a lot and gained much more confidence working with Rust overall. 100% success, might even develop it further.
While the code definitely isn’t anything to strive for, it may have some bits of code that’ll be useful to anyone wanting to start a similar project. It’s available here.