Monday, May 3, 2010

Control Configuration and Abstraction

The #1 thing that I've learned since shipping Replica Island is that users want configurable controls.  I mean, I might have guessed that some devices would have one sort of controller and not another, but I didn't anticipate the number of people who prefer a specific control configuration even when others are available.  Users with trackballs and directional pads asked for configurable keyboard settings, and when I added orientation sensor-based movement for devices without other controls (I'm looking at you, Xperia), many users who could already play the game chose to switch to tilt controls too.  I've made four updates so far and all of them have had to do with the input system; in the first three I added more and more configuration options, and in the most recent (v1.3) I rewrote the core input framework to improve non-standard control configurations.

When I started writing Replica Island, the only device available was the G1.  About half way through development I switched to an HTC Magic, and at the very end of development I switched to a Nexus One. The game was entirely designed around HTC's trackball-on-the-right design.  Fairly late in development, devices sporting directional pads (like the Motorola Cliq, and more importantly, the Droid) started to hit the market, so I added some support for d-pad controls.  I didn't really think anybody was going to use the keyboard to play, so I only added a few key-based controls to support the ODROID.

The input system started out like this:

MotionEvents from touch and trackball motion, as well as KeyEvents, were passed to the InputSystem (via the GameThread, for reasons I'd rather not discuss), which recorded them in some internal structures.  The goal here was to abstract the Android events from the game interface.  The game wants to be able to say things like "is the jump button pressed," or "was the jump button just pressed since the last frame," or "how long has it been since the last time the button was pressed."  It's a query-based interface, rather than the message-based interface that the Android framework provides.  So the initial role of the InputSystem was to record Android events so that they could be queried in the future.

The trackball was tricky to get right.  I want to allow the player to flick the trackball in a direction and have the character get an impulse in that direction scaled by the magnitude of the flick.  But the Android motion events come in at a fixed frequency and fixed magnitude, so in order to build a vector describing recent motion, I needed to maintain some history between motion events.  My first implementation, which survived for the entire course of development, was to cache a history of 10 motion events and calculate the average direction of motion across all of them to find the flick direction and magnitude.  After a specific timeout had passed with no new events, the cache was cleared and averaging would begin again with the next event.

This worked ok as a way to calculate a motion vector, but it had problems.  The biggest issue was that there was no way for a user to move slowly; even if the user rolled the ball slowly (thus causing motion events to come less frequently), as long as he rolled fast enough to make the internal event timeout, the events would get averaged together and would come out looking the same as a fast flick.  So users who tried to move with precision or in small steps often found themselves rocketing across the level.

When I went to add d-pad support, I just treated the pad as a different source of motion events.  I treated each keydown as a roll of a specific magnitude in a specific direction, and fed that into the same cache system I used for motion events.  This worked, sort of: it allowed me to pipe the directional pad through the trackball interface (which connected directly to the game) pretty easily, but it didn't feel good.  The problem with this approach was that directional pad events don't need any averaging; in fact, you want exactly the most recent state to be represented, as the player can release a key at any time (the trackball, unlike other kinds of input, never goes "up", and thus required a history).  So directional pad support in Replica Island, in the first few versions, sucked.

Add in configurable control options and very quickly my simple G1-centric input system grew into a mess that didn't work very well.  So, for the most recent version, I rewrote the whole thing.  Now the structure looks like this:

The main change here is to separate input recording (necessary for querying) from game-specific filtering and control configuration switching.  The InputSystem is now generic; it just records input events from the keyboard, touch panel, orientation sensor, and trackball, and provides an interface for the current state (as defined by the most recently received events) to be queried.  A new system, InputGameInterface, reads the hardware state from InputSystem, applies heuristics and filters, and presents fake buttons for the game to use.  This way the game can ask for the "directional pad" and get input from a trackball, orientation sensor, keyboard, directional pad, or whatever, already filtered and normalized.  I put all of the filtering code for the trackball into this class, and I can now pass directional pad input directly to the game without tying it to the trackball.

Speaking of the trackball, I changed my approach to filtering.  Now I accumulate trackball events that occur within a very short cutoff, and average them after a slightly longer cutoff.  Instead of turning the trackball input "off" after a fixed duration, I make it decay until it reaches zero.  This lets the user make small, precise movements, and still get a big motion from a large flick (as in the latter case, events occur in rapid succession and accumulate).  This method also gave me an obvious spot to stick a sensitivity factor for the trackball, which several users of devices with optical trackpads (HTC Desire, Samsung Moment, etc) had requested.

The new system probably needs a bit more tuning, but I played the game through with it and it feels pretty good.  The code is about 100x cleaner now, and InputSystem is something that others can easily reuse without any other dependencies.