GosuOsmos: Writing a Physics Game in Ruby

Osmos is a game about being a blob in a void full of other blobs. Absorb something smaller and you grow; touch something larger and it eats you. You move by ejecting part of yourself in the opposite direction, which means every course correction makes you smaller. It is a beautiful game built on one rule of physics.

My brother and I spent the holidays reimplementing it in Ruby, on top of the Gosu 2D library, mostly to find out how much of the game is the mechanic and how much is the presentation. The result is GosuOsmos: about 450 lines of Ruby, a starfield, and a lot of circles.

What a game is, structurally

Underneath the graphics, a game is a loop. Gosu runs it about sixty times a second and calls two methods on the window: update, which advances the world by one tick, and draw, which paints the current state. Keeping them separate is the whole discipline—update never draws, draw never changes anything.

def update
  unless @paused
    @speed.times do
      @universe.update
      ...
      @player.up    if button_down?(KbUp)
      @player.left  if button_down?(KbLeft)
      @player.mouse_click(Vector[mouse_x, mouse_y]) if button_down?(MsLeft)
    end
  end
end

Input arrives in two flavors. Continuous actions are polled: on every tick we ask whether the up arrow is down right now, because holding it should keep thrusting. Discrete actions are events: Gosu calls button_down(id) once per press, which is what pause, restart, and quit want. Wiring a one-shot action to the polling path gives you sixty restarts per second.

Drawing is ordered by an explicit ZOrder constant—background, life forms, UI, debug overlay—so the painter’s algorithm does the compositing for us. And because the physics assumes a constant tick rather than a measured delta time, “speed up” is implemented by running the update loop more times per frame rather than by taking bigger steps. Same reason a physics engine prefers a fixed timestep: the integrator is only well-behaved if you feed it the step it was tuned for.

Physics: mass, momentum, and lens-shaped bites

Every blob is a LifeForm holding a mass m, a position x, and a momentum p. Nothing stores a velocity or a radius, because both are derived. Velocity is p/m, and radius comes from treating each blob as a disc of uniform density: \[r = \sqrt{\frac{m}{\rho \pi}}.\]

Positions are updated by accumulating deltas during each tick, then applying momentum to position:

@p += @delta_p
@x += delta_x    # delta_x is @p / @m

Walls are elastic: reflect the position back across the boundary and negate that component of momentum.

Propulsion is the interesting part, because it is not a force—it is Newton’s third law with bookkeeping. Every impulse the player applies is added to their momentum and accumulated in an opposite-facing @ejection vector. Every ten ticks, that accumulated recoil is spent: a new LifeForm is spawned just behind the player, moving away, and its mass is subtracted from the player’s. Thrust literally costs you a piece of yourself, and the piece is now an object in the world that someone else can eat.

Collision detection is a brute-force pass over every pair, which is fine for a dozen blobs. Two circles interact when the distance between their centers is less than the sum of their radii. The area of the lens where two circles of radii \(r_1, r_2\) intersect at center distance \(d\) is \[A = r_1^2 \cos^{-1}\!\left(\frac{d^2 + r_1^2 - r_2^2}{2 d r_1}\right) + r_2^2 \cos^{-1}\!\left(\frac{d^2 + r_2^2 - r_1^2}{2 d r_2}\right) - \tfrac{1}{2}\sqrt{(-d + r_1 + r_2)(d + r_1 - r_2)(d - r_1 + r_2)(d + r_1 + r_2)}.\]

Multiply by the (uniform) density and you have the mass moving from the smaller blob to the larger one this tick, along with the momentum it carries. Absorption is therefore gradual and proportional to how deeply the two overlap: brushing past something costs a nibble, swallowing it head-on is quick.

Each blob accumulates @delta_m and @delta_p during collision checking and only applies them afterward, so a tick’s outcome does not depend on the order the array happens to be in. Then the universe prunes anything whose mass has reached zero.

The last piece is not physics but does the same job: enemies are tinted by mass relative to the player, from blue (safe to eat) through purple (about your size) to red (will eat you). It is one line of arithmetic, and it replaces every heads-up display the game would otherwise need.

It works!

GosuOsmos running: the white player blob surrounded by blue, purple, and red life forms on a starfield, with the debug console in the corner

The white blob in the middle is the player, and the yellow debug console tracks mass, momentum, and frame rate—including how much mass on the board is currently edible and how big the next blob up is, which together decide whether the game is still winnable.

Reimplementing a game is a good way to find out what makes it work. If you have not played the original, I strongly recommend it! You can buy it on Steam and it is a beautful version with a nice ambient soundtrack and additional mechanics.

Source is on Github.


© 2018. All rights reserved.

Powered by Hydejack v9.2.1