Click here to Skip to main content
15,867,834 members
Articles / Programming Languages / C#

Loot-Tables, Random Maps and Monsters - Part I

Rate me:
Please Sign up or sign in to vote.
4.97/5 (34 votes)
5 Oct 2012CPOL18 min read 86.7K   59   12
How to make Boss xy drop item abc and where does that rare mob come from?

Introduction

This is my first article on CodeProject, so please be kind to me. ;)

Did you ever wonder how those loot systems work in today's MMO's like WoW, Rift, and many others or H&S games like Diablo?

This is something that ran through my head for a long time, until I finally sat down and started thinking logical about it. While I thought through the requirements such a system has to fulfill, it became clear that almost everything that happens in those games, from the items a vendor offers for sale, to the loot a monster drops when it dies, and even the spawn of monsters (is it a rare mob or a normal monster, any kind of elite creature, whatever) can be put under the same hood: it is a kind of random generated ... thing. Even random generated maps are nothing else than a "drop of map segments".

Almost every game has things that happen "sometimes". If you are happy with code like "if (new Random().Next(1,10) < 5) ..." then you should probably stop reading - But if you want more than that, if you want to be able to just "design" probabilities for things to happen, if you do not want if...else if...else if... constructs running through random values, then this one here will be a pearl for you. I am quite confident. Go ahead. Read on!

I will show you my all-in-one solution to the "loot-problem", a class library called "RDS" (Random Distribution System) that creates recursive loot-tables, probabilities, result sets and has a set of properties that allows you to control its behavior. To my own surprise, the library was way smaller than I initially thought it would be when finished. The classes are slim, fast and easy to understand; somehow the RDS seems to be more than the sum of its parts. With only a few lines of code, you will be able to create fantastic randomized content!

No fancy graphics, no designers, just the core code creating loot for you. It's totally up to you and your imagination, what those classes can do for you and which designers you might want to write to create those tables (SQL based, File based, whatever). Would be great if you'd share some of your ideas with those classes here, too.

Background

In the first part of this article, I want to go into the theory of what it means, to develop a RDS. The second part will then bring all this to life. So if you want to see what it can do for you first, maybe you want to start with Part II and then go into the details here on Part I. Depends on your personal preferences, but expect to find some terms and methods in Part II that you will not fully understand without knowledge from this first part.

Let's take a look at what we see happen in the game and then break it down to what that means in technical terms. I will concentrate on two games that almost everybody knows (WoW and Diablo) for my examples, so the chances are good you have a picture in mind when I describe things.

Examples from WoW:

When a (normal outdoor) mob dies in WoW, the loot looks like this:

  • 3.56 Gold
  • 3 Silkweave cloth
  • Two-Handed Axe (green)

If you are lucky, the Axe might be blue or even epic colored.

Requirements that can be seen from this drop:

  • "Gold" drop has a random "amount" (something like "between 2 and 6 gold", or even better: completely dynamically based on a formula that includes the area level, monster level, player level to determine the range for the gold amount).
  • Amount of items is random (could as well be only one or two Silkweaves -> this is not the same as the gold amount! While the Gold is just added to your characters wallet, the cloth is clearly three items (= three instances of an object called "silkweave:cloth" or something similar), as they will be put into your inventory and the stack can be split into smaller stacks).

When you kill a boss in an instantiated dungeon (no matter whether this is a raid or a 5-man-instance), you have some guaranteed loot and some random loot:

  • 252 Gold
  • Epic armor piece 1 (guaranteed)
  • Epic armor piece 2 (guaranteed)
  • Rare crafting recipe (random)
  • 0 to 3 random magic (green) items

Requirements seen from this drop:

  • We need to be able to guarantee a specific minimum amount of items to drop, event if they come from the same loot-table (epic armor pieces) --> Not only a minimum amount, even a Count of drop queries needs to be possible
  • We need to be able to have a random number of counts (0 to 3 random magic items)

Let's put this into some code properties.

We will need a class that holds the "table". Let's name it RDSTable. This is, what in Gamer's terms is called a LootTable. Such a table will contain a list of items (or better: objects) that can drop. Without too much detail, we know that we want to allow the developer to put virtually any item in such a table, so we need an interface. We pick the name IRDSObject for this. The next logical step is to declare the contents of our table as IENumerable<IRDSObject> rdsContents; in our class. Now we can put any number of objects in a list to be picked. Not really hard so far, right?

Ok, what do we need to know about such an IRDSObject? What is a must-have here? We know, it will have a probability to drop. We know, there's a count involved. We know, it's possible to have it drop always. But when it drops always... as an opposite... isn't it a good idea, to include a switch, to make the item a unique drop? That it can be part of the result only once? Yes, this idea is good. We add that. And to add flexibility, we will add an enabled property too, so we can "turn off" parts of our table contents on demand, without modifying the table itself.

At the moment, our interface will then look like this (I removed the comments here to keep the code more compact. In the downloadable source, the code is, of course, fully documented):

All properties have the name prefix rds to have them together in IntelliSense and to avoid naming conflicts, as "Count" and "Enabled" are quite common names in C#. Feel free to rename them or work with explicit interface implementation. I personally prefer grouping-by-prefix (as all my textboxes start with txt, my listboxes with lst, etc.).

C#
public interface IRDSObject
{
 double rdsProbability { get; set; } // The chance for this item to drop
 bool rdsUnique { get; set; }        // Only drops once per query
 bool rdsAlways { get; set; }        // Drops always
 bool rdsEnabled { get; set; }       // Can it drop now?
}

How Probability Works

Why is the probability a double? Because it will be easier to modify it dynamically with multiplications and divisions, as an example, if the player character has modifiers (like the allmighty MagicFind in Diablo), the drop probability for each item can be multiplied dynamically at runtime with the MagicFind Bonus of the character.

Probability is neither a percent value nor an absolute thing. It's a value that describes the chance of being hit in relation to the other values of the table.

Let me give you a simple example:

Item 1 - Probability 1
Item 2 - Probability 1
Item 3 - Probability 1

All three items will have the same chance to drop.

Item 1 - Probability 10
Item 2 - Probability 5
Item 3 - Probability 1.5

The sum of all is 16.5 - If you calculate 16 drops from this table, you will likely have 10 times Item 1, 5 times Item 2 and maybe the 16th will be one single Item 3.

You get it? The result will just take a random value and loop through the contents of a table until it hits the first value that is bigger than the random value. This is the item hit. I will explain the exact functionality (and recursion) of the Result method later in this article.

Building a Table

Ok, then let's take a look at our RDSTable class. If we start with that as an interface too, we can make any class become a RDSTable in our game project. We do not want to put too many design rules on the developer's shoulders. If he needs some of his own base classes to be RDS-enabled, then he shall be able to do so. Beside the contents of our RDSTable, we will of course need a result set. As we have seen in the examples above, it's more than one IRDSObject we expect, so the Result will be an IEnumerable<IRDSObject>, too.

And now is one of the clou ideas: What if IRDSTable derives from IRDSObject? Great! Now each entry in the contents of a RDSTable, can be another (sub)table! That's one of the Jackpots we hit here - we make it recursive! This allows us, to design "Theme" tables, say, we put all epic world drops in one table (and each epic item in this table has its own probability), all our rares in a second table, all greens in a third and all white in a fourth table. We then set up a "Master Table" that contains those four tables as sub-tables, and each of those sub tables has its own set of properties, probabilities and values.

So, the first shot of IRDSTable will look like this:

C#
public interface IRDSTable : IRDSObject
{
 int rdsCount { get; set; }       // How many items shall drop from this table?
 IEnumerable<IRDSObject> rdsContents { get; } // The contents of the table
 IEnumerable<IRDSObject> rdsResult { get; }   // The Result set
}

The Count is part of the IRDSTable interface and not of the IRDSObject, because we want to ask "How many entries of this table shall drop?" and not "How often does this one item drop?". In the WoW example above (the silkweave clothes), we could assume, all "cloth" items are together in one table, their drop probabilities calculated dynamically based on a monster level formula (silk drops between levels 20 and 30, while mageweave drops only from 31++) that simply sets all probabilities to zero for cloth types this monster level can not drop.

More Details

After we put some known things into the interfaces, we now have a base where we can start thinking about details.

We are still missing tons of functionality, we can not tell the system to drop "0 to 3 green items", we have no control on the items when they drop (i.e., they are "hit" by the result evaluation) and we do not have any possibilities to modify probabilities immediately before a result-calculation occurs. And of course, we do not have control over the result set after it has been calculated.

Another thing we miss, is something like the Gold drops. We can only drop objects that implement IRDSObject. But we don't have values. Let's start with this first, as it is really easy. We want to drop a value of any kind. "Any kind"? Well, generics jump onto the stage now. We add an IRDSValue<T> interface to our model, that will derive from IRDSObject too and that adds a T Value property. This is, where we can store our Gold amount in a result.

C#
public interface IRDSValue<T> : IRDSObject
{
 T rdsValue { get; }
}

Now we can add integers, doubles, strings or any other object as "values" to our tables.

Taking Control of the Contents

This step is very important. We need to expand the IRDSObject interface with some more goodies. We want to be able to run over the probabilities of all items in the table before a result is calculated, we want to know, when an item is "hit" by the result calculator and maybe, we even want to have a chance to check the entire result set before it is returned to the caller.

To do this, we add some events to the IRDSObject interface, that give us control over these things.

I leave the comments on the events in this code snippet, they explain very well, when each of those events will happen.

C#
/// <summary>
/// Occurs before all the probabilities of all items of the current RDSTable
/// are summed up together.
/// This is the moment to modify any settings immediately before a result is calculated.
/// </summary>
event EventHandler rdsPreResultEvaluation;
/// <summary>
/// Occurs when this RDSObject has been hit by the Result procedure.
/// (This means, this object will be part of the result set).
/// </summary>
event EventHandler rdsHit;
/// <summary>
/// Occurs after the result has been calculated and the result set is complete, but before
/// the RDSTable's Result method exits.
/// </summary>
event ResultEventHandler rdsPostResultEvaluation;

void OnRDSPreResultEvaluation(EventArgs e);
void OnRDSHit(EventArgs e);
void OnRDSPostResultEvaluation(ResultEventArgs e);

Just a little bit more theory, then we will finally see the code of the result calculation which will put all the pieces together.

Implementing the Interfaces

The interface hierarchy for the library is very simple and looks like this:

Image 1

The library contains a full implementation of all interfaces. They are all named as their Interfaces without the leading "I", so the RDSObject class implements IRDSObject, RDSTable -> IRDSTable, and so on. Take a look at the attached source codes and the constructors I made.
The implementations are easy to read and straightforward.
Key class in the library is the RDSTable class which contains the Result calculation implementation that is used by RDS. We will take a very close look at this core functionality in the following chapters.

When you use the RDS, you do not have to implement those interfaces, just make your baseclass for your game objects and monsters derive from RDSObject and you will be able to add each of them to any result set.

Null Values: The RDSNullValue Class

One open issue we have, is the "0 to 3 green items" functionality. I do not want the Count property to be randomized, I choose a better approach. Null values! We just create a class called RDSNullValue : RDSObject that can be added to each loot table and have its own probability. With this, we can easily solve this issue. We create the table for the green drops with a Count of 3 and just add a RDSNullValue to the table with a given probability to just return "nothing". This is how "0 to 3" is implemented.

For simplicity, the table could look like this:

Null - Probability 1
Green Item - Probability 2

So, in theory, every third drop is a null drop - but of course, we will have queries where all three hit a green item and we will have drops, where null is hit twice or even three times. You can very easily increase/decrease the null-chance by modifying the probabilities of either the green item or the null value.

The RDSNullValue class is very very simple but solves a lot of problems because it allows us to drop "nothing" when we need it.

C#
/// <summary>
/// This is the default class for a "null" entry in a RDSTable.
/// It just contains a value that is null (if added to a table of RDSValue objects),
/// but is a class as well and can be checked via a "if (obj is RDSNullValue)..." construct
/// </summary>
public class RDSNullValue : RDSValue<object>
{
 public RDSNullValue(double probability)
  : base(null, probability, false, false, true) { }
}

The Randomizer

Oh this is a topic where you can write books about it. Computers are not able to create "real" random numbers and all that stuff... I do not want to go into too much detail about that philosophical discussion. It's right yes, they are not real random, but they are more or less unpredictable. Anyway, I decided to put that decision away from me, and I created a static class, the RDSRandomizer. By default, it just uses .NET's Random class. If you want to use the RNGCryptoServiceProvider class from the System.Security.Cryptography namespace, you may well do it. The RDSRandomizer class allows to exchange the randomizer used via the SetRandomizer() method. The only question you should ask yourself is: "Do I really need it?". No one can tell anyway in your running game, why or how close to the "epic item" the drop of a monster was. As long as you don't deal with real money gambling (like Poker Software or Casino Software)... in a "normal fun game", a standard randomizer is... hmm... Random enough.

To allow the developer to change the Randomizer used, the method accepts any class derived from .NET's Random class. Almost all methods of Random are virtual, because Microsoft had the same idea: People will maybe want to change this. So feel free to create your own Randomizer, as long as it derives from Random, you are fine, and you can replace my default implementation with the SetRandomizer() method.

RDSRandom has some methods that are useful in most games at any point, here is a quick overview of the methods:

C#
public static double GetDoubleValue(double max)             // From 0.0 (incl) to max (excl)
public static double GetDoubleValue(double min, double max) // From min (incl) to max (excl)
public static int GetIntValue(int max)                      // From   0 (incl) to max (excl)
public static int GetIntValue(int min, int max)             // From min (incl) to max (excl)
// Rolls a given number of dice with a given number of sides per dice.
// Result contains as first entry the sum of the roll
// and then all the dice values
// Example: RollDice(2,6) rolls 2 6-sided dice and the result will look like this
// {9, 5, 4}  ... 9 is the sum, one rolled a 5, the second one a 4
public static IEnumerable<int> RollDice(int dicecount, int sidesperdice)
// A simple method to check for any percent chance.
// The value must be between 0.0 and 1.0, so a 10% chance is NOT "10", it's "0.10"
public static bool IsPercentHit(double percent)

With those few simple methods, you can easily do most of the random stuff in a game that does not depend on RDSTables, with the great addition, that you might have replaced the default .NET Randomizer with your own.

How Result Calculation Works

I think it is a good time now to explain, how result calculation will work in the implementation, before confusion gets too high. So I will just show what the Result actually does to help you with your imagination.

I implemented the Result in a getter, yes I know, some of you will say, this is bad, but honestly, I really like that. Feel free to convert it to a method, if that fits your style better.

The code of the result method is well documented, but I will add further explanations after the code.

C#
// Any unique drops are added here when they are hit.
// Anything contained here can not drop a second time.
private List<IRDSObject> uniquedrops = new List<IRDSObject>();
// Calculate the result
public virtual IEnumerable<IRDSObject> rdsResult
{
 get
 {
  // The return value, a list of hit objects
  List<IRDSObject> rv = new List<IRDSObject>();
  uniquedrops = new List<IRDSObject>();
  // Do the PreEvaluation on all objects contained in the current table
  // This is the moment where those objects might disable themselves.
  foreach (IRDSObject o in mcontents)
   o.OnRDSPreResultEvaluation(EventArgs.Empty);
  // Add all the objects that are hit "Always" to the result
  // Those objects are really added always, no matter what "Count"
  // is set in the table! If there are 5 objects "always", those 5 will
  // drop, even if the count says only 3.
  foreach (IRDSObject o in mcontents.Where(e => e.rdsAlways && e.rdsEnabled))
   AddToResult(rv, o);
  // Now calculate the real dropcount, this is the table's count minus the
  // number of Always-drops.
  // It is possible, that the remaining drops go below zero, in which case
  // no other objects will be added to the result here.
  int alwayscnt = mcontents.Count(e => e.rdsAlways && e.rdsEnabled);
  int realdropcnt = rdsCount - alwayscnt;
  // Continue only, if there is a Count left to be processed
  if (realdropcnt > 0)
  {
   for (int dropcount = 0; dropcount < realdropcnt; dropcount++)
   {
    // Find the objects, that can be hit now
    // This is all objects, that are Enabled and that have not
    // already been added through the Always flag
    IEnumerable<IRDSObject> dropables = mcontents.Where(e => e.rdsEnabled && !e.rdsAlways);
    // This is the magic random number that will decide, which object is hit now
    double hitvalue = RDSRandom.GetDoubleValue(dropables.Sum(e => e.rdsProbability));
    // Find out in a loop which object's probability hits the random value...
    double runningvalue = 0;
    foreach (IRDSObject o in dropables)
    {
     // Count up until we find the first item that exceeds the hitvalue...
     runningvalue += o.rdsProbability;
     if (hitvalue < runningvalue)
     {
      // ...and the oscar goes too...
      AddToResult(rv, o);
      break;
     }
    }
   }
  }
  // Now give all objects in the result set the chance to interact with
  // the other objects in the result set.
  ResultEventArgs rea = new ResultEventArgs(rv);
  foreach (IRDSObject o in rv)
   o.OnRDSPostResultEvaluation(rea);
  // Return the set now
  return rv;
 }
}

Step-by-step explanation:

  • The list uniquedrops contains all hit items that are set to rdsUnique = true.
  • First, we call the OnRDSPreResultEvaluation method for all entries of the current table. This is the point where you can disable entries, modify the probabilities, whatever you need to do before the randomizer picks the "golden value".
  • Then, all items (that are enabled) with rdsAlways = true are added to the result set. No Randomizer needed... always is always, BUT: If the table has, let's say, a Count = 5 and you have 2 items set to rdsAlways = true, this means, only three more items will be picked from the rest of the table to avoid exceeding the drop maximum of 5. You find this in the code where realdropcount is calculated.
  • Next step is to evaluate all "dropable" items. This is all those items, that are rdsEnabled = true and not set to rdsAlways = true, because those have already been added.
  • We then loop through the remaining count of items (realdropcount) and generate a RDSRandom value for each of them. The while loop counts up until the runningvalue exceeds the hitvalue. This is our hit item. It will be added to the result set, and the OnRDSHit event is fired for this item (this is done by the AddToResult method which is explained below).
  • At the end, the OnRDSPostResultEvaluation is triggered for each item contained in the result set. There may be times where you want to look over the result set to modify it before it is finally returned to the caller.

AddToResult does some key action on all this:

  • It creates the recursion when you have set up a table-of-tables-of-tables-of-tables structure.
  • It takes care of rdsUnique = true drops
  • It introduces a so far not shown concept of the RDSCreateableObject (explained later)
C#
private void AddToResult(List<IRDSObject> rv, IRDSObject o)
{
 if (!o.rdsUnique || !uniquedrops.Contains(o))
 {
  if (o.rdsUnique)
   uniquedrops.Add(o);
  if (!(o is RDSNullValue))
  {
   if (o is IRDSTable)
   {
    rv.AddRange(((IRDSTable)o).rdsResult);
   }
   else
   {
    // INSTANCECHECK
    // Check if the object to add implements IRDSObjectCreator.
    // If it does, call the CreateInstance() method and add its return value
    // to the result set. If it does not, add the object o directly.
    IRDSObject adder = o;
    if (o is IRDSObjectCreator)
     adder = ((IRDSObjectCreator)o).rdsCreateInstance();
    rv.Add(adder);
    o.OnRDSHit(EventArgs.Empty);
   }
  }
  else
   o.OnRDSHit(EventArgs.Empty);
 }
}

Step-by-step:

  • First is the unique-check. If it is rdsUnique = true and not contained in the unique list so far, add it. If it is already contained, skip it (thats the if (!unique || !contained)... statement)
  • Next is the NullValue check. A NullValue will not be added to the result set.
  • Then the recursion check happens. If the item hit is another (sub)table, .AddRange the result of this table (where everything happens again... events, hits, results).
  • If it is not a table, add it to the result.

In the next chapter, I explain the IRDSObjectCreator interface which is a very important part of the system.

As the RDSNullValue can be hit, I decided to fire the OnRDSHit event on the NullValue object too, even when in most cases, the default null value will be used, but it allows you to derive your own null value and even can react on it when hit. Think of disabling something in your game, when any dropchance xy results in a null-value, speak it as "react on something that does not happen".

The IRDSObjectCreator Interface

This is one very important thing. You add references to your tables. So if you query a table multiple times, there are always the same references returned in the result set. This is nothing critical when you drop Gold or other dead things. But it is critical, when you drop something living, like a Monster or a Map segment. When all dropped monsters have the same reference, we make it easy for the hero of our game. If he kills one of them, they all die immediately Smile. So we need a new instance of each of the objects when they drop. This is where this interface (or the RDSCreatableObject class which implements it) comes into play.

It offers only one single method: CreateInstance(). This method is of course virtual, so it can (and should) be overwritten. By default, it just returns a new() of the default constructor of the type of the object it is.

Look at the code of RDSCreateableObject for a better understanding:

C#
/// <summary>
/// This class is a special derived version of an RDSObject.
/// It implements the IRDSObjectCreator interface, which can be used 
/// to create custom instances of classes 
/// when they are hit by the random engine.
/// The RDSTable class checks for this interface before a result is added to the result set.
/// If it is implemented, this object's CreateInstance method is called, 
/// and with this tweak it is possible
/// to enter completely new instances into the result set at the moment they are hit.
/// </summary>
public class RDSCreatableObject : RDSObject, IRDSObjectCreator
{
 /// <summary>
 /// Creates an instance of the object where this method is implemented in.
 /// Only paramaterless constructors are supported in the base implementation.
 /// Override (without calling base.CreateInstance()) to instantiate more complex constructors.
 /// </summary>
 /// <returns>A new instance of an object of the type where this method is implemented
 /// </returns>
 public virtual IRDSObject rdsCreateInstance()
 {
  return (IRDSObject)Activator.CreateInstance(this.GetType());
 }
}

If you need anything other than the default constructor, you should override this method.

Now you have seen all the classes and interfaces that are part of the RDS. The object model is very simple too, it looks like this:

Image 3

And now, read Part II of this article, which concentrates on using this library with some nice examples of random maps, monster spawns, item loots and even random events happening during the runtime of a game.

Summary

We created a RDS that allows us to do these things:

  • Drop any number of... things with given probabilities in a recursive structure
  • Drop nothing
  • React on events (or overrides) when certain things happen
  • Simulate loot behavior of big players in the game industry
  • Add values or references, re-create instances of living objects
  • The option to replace the default.net Randomizer with something more sophisticated
  • Basically, you can delegate every random decision and chance in your game to RDS

All we need to have fun while making and playing our games is there. The only thing you have not seen so far is, how that all comes to live. Fortunately, there is a Part II, which will exactly do that!

Check it out!

Continue with Part II here.

Yours,

Mike

History

  • 2012-07-13 First draft completed
  • 2012-10-05 Removed false linked smileys

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
Austria Austria
Software Developer since the late 80's, grew up in the good old DOS-Era, switched to windows with Win95 and now doing .net since early 2002 (beta).
Long year c# experience in entertainment software, game programming, directX and XNA as well as SQLServer (DBA, Modelling, Optimizing, Replication, etc) and Oracle Databases in Enterprise environments. Started with Android development in 2014.

Developer of the gml-raptor platform (See my github profile below).

My Game Developer Profile at itch.io
My Repositories at github

Comments and Discussions

 
GeneralMy vote of 5 Pin
Anton Shekhov25-Oct-17 11:12
Anton Shekhov25-Oct-17 11:12 
GeneralMy vote of 5 Pin
jgdm11712-Nov-13 15:05
jgdm11712-Nov-13 15:05 
GeneralRe: My vote of 5 Pin
Mike (Prof. Chuck)4-Dec-13 21:08
professionalMike (Prof. Chuck)4-Dec-13 21:08 
GeneralMy vote of 5 Pin
César de Souza5-Oct-12 10:24
professionalCésar de Souza5-Oct-12 10:24 
GeneralRe: My vote of 5 Pin
Mike (Prof. Chuck)6-Oct-12 2:49
professionalMike (Prof. Chuck)6-Oct-12 2:49 
GeneralMy vote of 5 Pin
DrABELL4-Oct-12 15:24
DrABELL4-Oct-12 15:24 
GeneralRe: My vote of 5 Pin
Mike (Prof. Chuck)4-Oct-12 20:28
professionalMike (Prof. Chuck)4-Oct-12 20:28 
GeneralRe: My vote of 5 Pin
DrABELL5-Oct-12 2:28
DrABELL5-Oct-12 2:28 
GeneralMy vote of 5 Pin
cansino4-Oct-12 4:08
cansino4-Oct-12 4:08 
GeneralGood Article Pin
hex7050316-Jul-12 5:44
hex7050316-Jul-12 5:44 
GeneralRe: Good Article Pin
Mike (Prof. Chuck)16-Jul-12 18:57
professionalMike (Prof. Chuck)16-Jul-12 18:57 
GeneralMy vote of 5 Pin
Christian Vogt16-Jul-12 3:03
professionalChristian Vogt16-Jul-12 3:03 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.