DEV Community

Cover image for Ghost Trap: My TeenyTiny DragonRuby MiniGameJam Entry
Jess
Jess

Posted on • Edited on

Ghost Trap: My TeenyTiny DragonRuby MiniGameJam Entry

A few weeks ago I wrote a post about the Dragonruby game engine. I mentioned that there was a month-long game jam to make a 20 second game. This post is about my entry: Ghost Trap.

Try it out on itch. If the html version is a little wonky you can download your OS specific version. I tried it on 3 different computers and it was fine on 2 of them, but the older Windows machine could not handle it.


When I was a kid I really liked the Ghostbusters game for the Sega Master System. It's not a great game, but I liked stopping the ghosts from entering buildings, driving and catching the ghosts, and trapping the ghosts at the homes they were haunting. I decided I would make a game that was similar to trapping the ghosts.

In the Sega game, you get to your destination and you have 2 ghostbusters to move. You move one where you want him, then use the other to guide the ghosts between the two of them toward the trap.

I didn't want to make a clone, so I decided to make it so a single "ghostcatcher" catches ghosts in their beam, which get sucked into their backpack. The backpack can only hold 10 ghosts, so the ghostcatcher has to periodically deposit ghosts into the ghost disposal canister. The objective is to trap as many ghosts as you can in 20 seconds. You can earn a combo the more ghosts you catch on your beam at once and your beam energy depletes over time to keep the player from holding the button down the entire 20 seconds. You have to stop shooting to regenerate energy.

Oh yeah, this is a world of dogs instead of people, because why not? Truthfully I didn't want to draw a ghost that looked like a traditional emoji looking 👻 ghost, so I made a dog-ghost. Plus, this gave me an excuse to use my dog Pixel as a reference for my ghostcatcher.

ghost dog
Ghostcatcher Pixel

Artwork

I made the art with a combination of Affinity Designer and Procreate on the iPad and Affinity Photo on the Mac. I never used the Affinity software before so I was learning as I went along. Affinity Designer is really cool and I enjoyed working with it once I started to get the hang of it, but I really miss having access to Photoshop which is something I'm quite comfortable with.

Gameplay screenshot

Code Organization

I broke my code down into 6 classes and a few helper methods. I think some of the code I wrote was possibly handled by Dragonruby, but since I'm still learning how it all works I didn't take advantage of all of its features.

Entity

The Entity class is the parent for my other game object classes. It has properties for sprite dimensions and attributes, collision, and rendering.

# an excerpt of the Entity class class Entity attr_accessor :x, :y, :w, :h, :sprite_path, :flip, :alpha def initialize(x, y, w, h, sprite_path, flip) @w = w @h = h @x = x @y = y @sprite_path = sprite_path @flip = flip @alpha = 255 end def rect [x, y, w, h] end def render [ x, y, w, h, sprite_path, 0, # ANGLE self.alpha, # ALPHA 255, # RED SATURATION 255, # GREEN SATURATION 255, # BLUE SATURATION 0, # TILE X 0, # TILE Y self.w, # TILE W self.h, # TILE H self.flip, # FLIP HORIZONTALLY false # FLIP VERTICALLY ] end def is_colliding_with?(obj) self.rect.intersect_rect?(obj.rect) end #... etcc end 
Enter fullscreen mode Exit fullscreen mode

I made the rect method to pass to the built-in intersect_rect to check for collision, but I believe it might be something I could have handled with Dragonruby natively. That's something I have to research further.

Player

The Player class is a subclass of Entity and handles all the player specific stuff like moving, shooting, catching ghosts, etc.

# an excerpt of the Player class class Player < Entity attr_accessor :total_ghosts_held, :backpack_limit, :beam, :is_shooting, :ghosts_on_beam, :beam_power, :beam_cooldown, :speed, :is_walking, :sprite_frame, :shoot_sound_playing MAX_BEAM_POWER = 200 BEAM_COOLDOWN = 1 def initialize w = 114 h = 300 super($WIDTH/2-w/2, 90, w, h, "sprites/player_green_1.png", false) @total_ghosts_held = 0 # total ghosts in pack @backpack_limit = 10 @beam = {x: ((self.x + self.w)/2).to_i, y: self.y+h, h: 300, w: 23} @is_shooting = false @ghosts_on_beam = [] @beam_power = MAX_BEAM_POWER @beam_cooldown = 0 @speed = 6 @is_walking = false @sprite_frame = 0 @shoot_sound_playing = false end def set_sprite status_color = self.space_in_pack? ? "green" : "red" self.sprite_path = "sprites/player_#{status_color}_#{self.sprite_frame+1}.png" end def calc(outputs, tick_count) self.sprite_frame = self.is_walking ? tick_count.idiv(6).mod(2) : 0 if self.can_shoot? && self.is_shooting play_sound(:shoot) if !shoot_sound_playing self.shoot_sound_playing = true if !self.shoot_sound_playing self.shoot(outputs, tick_count) elsif self.has_ghosts_on_beam? self.shoot_sound_playing = false add_score(self.total_ghosts_on_beam) self.store_ghosts_from_beam_to_pack self.ghosts_on_beam.clear end self.refill_beam if !self.is_shooting && self.beam_power != MAX_BEAM_POWER end end 
Enter fullscreen mode Exit fullscreen mode

Ghost

The ghost class handles spawning ghosts, and each instance can keep track of whether or not it has free will (if it's caught in the player's beam or can move freely) and if it's invulnerable. Ghosts flicker in and out and are invulnerable to being caught if they are transparent.

# an excerpt of the Ghost class  class Ghost < Entity attr_accessor :is_flickering, :is_invulnerable, :has_free_will, :is_in_beam, :id def calc(tick_count) # use different sprite if ghost is on beam self.sprite_path = !self.is_in_beam ? "sprites/ghost80.png" : "sprites/ghost_on_beam.png" # keep in bounds self.y = $HEIGHT if self.y < 0 || self.y > $HEIGHT self.x = 0 if self.x < 0 self.x = $WIDTH - self.w if self.x + self.w > $WIDTH self.toggle_flickering if tick_count % 60 == 0 self.flicker if self.is_flickering self.move_freely(tick_count) if self.has_free_will end def stop_flickering # play_sound(:flicker_in) self.is_flickering = false self.is_invulnerable = false self.alpha = 255 end def start_flickering # play_sound(:flicker_out) if self.has_free_will self.is_flickering = true self.is_invulnerable = true end end def self.spawn x = random_int(20, $WIDTH - 100) y = random_int(400, $HEIGHT - 100) Ghost.new(x, y) end 
Enter fullscreen mode Exit fullscreen mode

Disposal

Disposal is the canister where the player deposits ghosts. It keeps track of how many ghosts it contains and its open/closed status to handle which sprite should display.

# An excerpt of the Disposal class class Disposal < Entity attr_accessor :total_ghosts, :is_open, :timer def initialize super(566, 221, 99, 154, 'sprites/canister.png', false) @is_open = false @total_ghosts = 0 @timer = 1 end def deposit_ghosts(total_to_add) play_sound(:dispose) self.is_open = true self.total_ghosts += total_to_add end def calc(tick_count) self.timer -=1 if self.is_open self.is_open = false if timer <= 0 && tick_count % 10 == 0 end def open_canister self.sprite_path = OPEN[:sprite_path] self.w = OPEN[:w] self.h = OPEN[:h] end def close_canister self.timer = 1 self.sprite_path = CLOSED[:sprite_path] self.w = CLOSED[:w] self.h = CLOSED[:h] end def render # set sprite based on open status self.is_open ? open_canister : close_canister super end end 
Enter fullscreen mode Exit fullscreen mode

GhostTrap

Finally, there is the GhostTrap class, which is the game class that handles all the game functionality and pieces everything together.

The defaults method sets up all of the properties in state. Normally I would use instance variables, but Dragonruby has a built-in variable called state that is passed from tick to tick.

def defaults state.player ||= Player.new state.disposal ||= Disposal.new state.ghosts ||= MAX_GHOSTS.map { Ghost.spawn } state.mode ||= :title state.timer ||= 20 state.score ||= 0 state.countdown ||= 3 end 
Enter fullscreen mode Exit fullscreen mode

All rendering (drawing to the screen) are organized within the render method, and all updates are handled within the calc method. User inputs are handled in process_inputs. I broke everything down further depending on what game screen the user is on.

def render render_title if state.mode == :title render_instructions if state.mode == :instructions render_credits if state.mode == :credits render_play if state.mode == :play render_game_over if state.mode == :game_over end 
Enter fullscreen mode Exit fullscreen mode

Problems

I had the most trouble working with sound effects. I was able to get basic sound effects working, but I couldn't figure out how to get an effect that wasn't music to loop and stop when I wanted it to. I wanted a whirring effect for the player's beam but I settled for an initial effect each time the player first presses the spacebar to shoot. I did this by adding an additional variable, shoot_sound_playing which I set to true when the player shoots, as long as it's not already true. Then I set it to false when the player stops shooting.

The Dragonruby docs recommend you go through each sample game thoroughly to really learn the engine, so I went through what I could but I didn't have time to really dig into everything. I think if I go back and look through the code when I get a chance I can probably do some refactoring and make some improvements to my game.

 if self.can_shoot? && self.is_shooting play_sound(:shoot) if !shoot_sound_playing self.shoot_sound_playing = true if !self.shoot_sound_playing self.shoot(outputs, tick_count) # ... etc 
Enter fullscreen mode Exit fullscreen mode

You can find the full game code on my github

Side note: I know I used self sometimes and not other times. Personally I prefer to use it because then I know exactly where a method is coming from and to me that's more readable, but Dragonruby doesn't use it and I know Ruby is all about being free of clutter. I'm still in the process of cleaning everything up and making it consistent, but I wanted to get this post out asap. :)

Top comments (0)