Click here to Skip to main content
15,881,173 members
Articles / Programming Languages / C#

Loot-Tables, Random Maps and Monsters - Part II

Rate me:
Please Sign up or sign in to vote.
4.92/5 (24 votes)
17 Jul 2012CPOL10 min read 51.5K   920   33   15
Bringing RDS to life - How it all works.

Introduction

So you have gone all the long path of dry theory through Part I and want to see how it runs? Welcome to Part II of the RDS article!

I will show you now the "how-to's" with a group of small demos (you find all the demo code in the downloadable source code) - it's a simple Console Application as I promised in Part I "no fancy graphics, no designers, just code" that will output the results of RDS.

Demo 1 - A Simple "pick 2 out of 6" Table

We create a RDSTable object, add 6 items to the table and then let RDS pick two of them at random. We then play around with the table, make one of the entries rdsAlways=true to see, that this one will then be included in the result with every query. Play with the probabilities of the items to see, how the drops change.

The code is very simple and straightforward: Create a RDSTable, add 6 entries and set the rdsCount=2. This will make the system loot 2-out-of-6. (You see, adding entries "by hand" is not the way to go for the future. There's a designer tool needed to have a good supporting GUI to set up and modify your tables and to just load them from a file or database at run time).

C#
RDSTable t = new RDSTable();
// Add 6 items with equal probability to the table
t.AddEntry(new MyItem("Item 1"), 10);
t.AddEntry(new MyItem("Item 2"), 10);
t.AddEntry(new MyItem("Item 3"), 10);
t.AddEntry(new MyItem("Item 4"), 10);
t.AddEntry(new MyItem("Item 5"), 10);
MyItem m6 = new MyItem("Item 6"); // We need this item later
t.AddEntry(m6, 10);
// Tell the table we want to have 2 out of 6
t.rdsCount = 2;
// First demo: Simply loot 2 out of the 6
Console.WriteLine("Step 1: Just loot 2 out 6 - 3 runs");
for (int i = 0; i < 3; i++)
{
 Console.WriteLine("Run {0}", i + 1);
 foreach (MyItem m in t.rdsResult)
  Console.WriteLine("    {0}", m);
}
// Now set Item 6 to drop always
m6.rdsAlways = true;
Console.WriteLine("Step 2: Item 6 is now set to Always=true - 3 runs");
for (int i = 0; i < 3; i++)
{
 Console.WriteLine("Run {0}", i + 1);
 foreach (MyItem m in t.rdsResult)
  Console.WriteLine("    {0}", m);
}

Here's an output of Demo 1 (As this is a random system, your output will likely looks different, when you run the demo):

*** DEMO 1 STARTED ***
----------------------
Step 1: Just loot 2 out 6 - 3 runs
Run 1
    Item 3
    Item 1
Run 2
    Item 6
    Item 2
Run 3
    Item 4
    Item 5
Step 2: Item 6 is now set to Always=true - 3 runs
Run 1
    Item 6
    Item 2
Run 2
    Item 6
    Item 2
Run 3
    Item 6
    Item 4
-----------------------
*** DEMO 1 COMPLETE ***

Demo 2 - Simple Recursion. A Table Containing Three Tables and Play with rdsUnique = true

A simple recursive structure is set up:

C#
RDSTable t = new RDSTable();
RDSTable subtable1 = new RDSTable();
RDSTable subtable2 = new RDSTable();
RDSTable subtable3 = new RDSTable();
t.AddEntry(subtable1, 10); // we add a table to a table thanks to the interfaces
t.AddEntry(subtable2, 10);
t.AddEntry(subtable3, 10);
subtable1.AddEntry(new MyItem("Table 1 - Item 1"), 10);
subtable1.AddEntry(new MyItem("Table 1 - Item 2"), 10);
subtable1.AddEntry(new MyItem("Table 1 - Item 3"), 10);
subtable2.AddEntry(new MyItem("Table 2 - Item 1"), 10);
subtable2.AddEntry(new MyItem("Table 2 - Item 2"), 10);
subtable2.AddEntry(new MyItem("Table 2 - Item 3"), 10);
subtable3.AddEntry(new MyItem("Table 3 - Item 1"), 10);
subtable3.AddEntry(new MyItem("Table 3 - Item 2"), 10);
subtable3.AddEntry(new MyItem("Table 3 - Item 3"), 10);

In the first step, you see the recursion happening, in the second step, we increase the count to 10 and set table 2 to rdsUnique=true. You can see, all tables get multiple hits, but there's only 1 records from table 2 in the result set (no matter, how many items or even more subtables are contained in table2!).

You see that even when we set rdsCount=10, there is not always really 10 items in the result! The reason for this is the rdsUnique=true, as RDS skips all subsequent hits from table 2. This is why you get a smaller count in the result as you might have expected.

C#
Step 1: Loot 3 items - 3 runs
Run 1
    Table 2 - Item 1
    Table 1 - Item 3
    Table 2 - Item 2
Run 2
    Table 3 - Item 2
    Table 2 - Item 1
    Table 3 - Item 1
Run 3
    Table 2 - Item 3
    Table 2 - Item 1
    Table 2 - Item 3
Step 2: Table 2 is now unique, loot 10 items - 3 runs
Run 1
    Table 1 - Item 2
    Table 2 - Item 2
    Table 3 - Item 1
    Table 3 - Item 3
    Table 1 - Item 3
    Table 1 - Item 3
    Table 1 - Item 3
Run 2
    Table 1 - Item 1
    Table 2 - Item 2
    Table 3 - Item 2
    Table 3 - Item 1
    Table 3 - Item 1
    Table 3 - Item 2
    Table 3 - Item 2
Run 3
    Table 2 - Item 3
    Table 1 - Item 1
    Table 3 - Item 2
    Table 3 - Item 1
    Table 3 - Item 2
    Table 1 - Item 2

Demo 3 - Dynamic Formulas. Changing Probabilities at Runtime

Catching the PreResultEvaluation and modifying parameters before a result is calculated. For this demo, the class MyItem has been derived to MyItemDemo3. This class will override the OnPreResultEvaluation method from the RDSObject base class and dynamically modify the probability based on a simple formula: With every result requested, our probability increases by 5% until we get hit. When we are hit, the probability is reset to the default of 1.

MyItemDemo3 will set its own probability in the constructor based on the parameter "dynamic", then overrides two events (the Pre and the Hit) to control the probability and makes an output when hit.

If the item is dynamic, it starts with a probability of 1, otherwise with 100. This is for the demo to show you the increase of the probability until the item finally gets hit.

C#
public class MyItemDemo3 : MyItem
{
 public MyItemDemo3(string name, bool isdynamic)
  : base(name)
 {
  mdynamic = isdynamic;
  rdsProbability = (mdynamic ? 1 : 100);
 }
 private bool mdynamic = false;
 public override void OnRDSPreResultEvaluation(EventArgs e)
 {
  // My probability increases by 5% with every query until i get hit...
  if (mdynamic)
  {
   rdsProbability *= 1.05;
  }
 }
 public override void OnRDSHit(EventArgs e)
 {
  // i am hit! Reset to default probability
  if (mdynamic)
  {
   rdsProbability = 1;
   Console.WriteLine("Dynamic hit! Reset probability to 1");
  }
 }
...
...
...

The running code for this demo looks like this: We set up a simple table with 5 items, one of them being the dynamic one. Then we loop through the results until the dynamic item gets hit:

Loot until we hit the dynamic item
Dynamic is now: Item 1 @ 1,0000
Loot: Item 2
Dynamic is now: Item 1 @ 1,0500
Loot: Item 3
Dynamic is now: Item 1 @ 1,1025
Loot: Item 2
Dynamic is now: Item 1 @ 1,1576
Loot: Item 3
Dynamic is now: Item 1 @ 1,2155
Loot: Item 4
Dynamic is now: Item 1 @ 1,2763
Loot: Item 4
Dynamic is now: Item 1 @ 1,3401
Loot: Item 4
Dynamic is now: Item 1 @ 1,4071
Loot: Item 4
Dynamic is now: Item 1 @ 1,4775
Loot: Item 5
...
...
...
Dynamic is now: Item 1 @ 38,8327
Loot: Item 2
Dynamic is now: Item 1 @ 40,7743
Loot: Item 4
Dynamic is now: Item 1 @ 42,8130
Loot: Item 3
Dynamic is now: Item 1 @ 44,9537
Loot: Item 5
Dynamic is now: Item 1 @ 47,2014
Loot: Item 3
Dynamic is now: Item 1 @ 49,5614
Dynamic hit! Reset probability to 1
Loot: Item 1 @ 1,0000

Demo 4 - Creating (Spawning) a Group of Monsters, Maybe Even With a Rare Mob?

Ok, we need a group of Goblins. Urgent! Shamans, Warriors and, with luck, the almighty BOB! The Goblin the world fears since it heard of him the first time! Smile. Let's find out how we can create a random set up group of Monsters. This demo shows the usage of the RDSCreatableObject class.

The preparation for this demo includes creating a "Goblin" base class (which is basically just the same as the "MyItem" class from the other Demos) , from which we derive the Warrior and the Shaman. The almighty BOB, our rare mob will be of course a Warrior, so we derive BOB : Warrior. We then set up a RDSTable that will contain 5 Shamans, 5 Warriors... and BOB.

Why 5 of each class? As I want to show in the demo, one possible way to have monsters spawn with different levels. For the Demo, we set a variable "AreaLevel = 10" as the level of the Area where we want to spawn our group of monsters. We then add 1 Shaman with AreaLevel-2, 1 with AreaLevel-1, one at par with AreaLevel, and one with +1 and +2. Same for the Warriors. The +2/-2 mobs have a lower probability to spawn, and the even level mobs have the highest probability.

And last but not least, we add BOB with a significantly lower probability to spawn. BOB is rdsUnique of course... there can be only one BOB.

Play around with this demo, run it over and over again, until you finally hit BOB. See how the group of monsters looks like in their distribution of levels and types (Shaman, Warrior) and you will see, that this spawns totally random groups of 10 shamans each.

Maybe you want to enhance this demo to make the count of Goblins spawned random, too. Try to add a NullValue or set up another table of RDSValue<T> objects (or just roll a dice) to determine the rdsCount for the table.

Here is one possible output of Demo 4, showing the different levels of Goblins based on their probability settings:

Enter Area Level: 20
Spawning Goblins in a Level 20 area:
Shaman - Level 20
Warrior - Level 20
Shaman - Level 22
Shaman - Level 21
Shaman - Level 20
Warrior - Level 22
Shaman - Level 20
Shaman - Level 20
Warrior - Level 20
Shaman - Level 22

You see a well spread random group of Goblins, in this case slightly more Shamans than Warriors, but the next group could very well be a bunch of Warriors with almost no Shamans...

By entering a zero as the area level in this demo, you make it loop until BOB is finally discovered in a group of Goblins. The output then looks like this:

Enter 0 as area level to loop random levels until you hit BOB!
Enter Area Level: 0
BOB IS HERE! ON YOUR KNEES, WORLD! *haaarharharhar*
BOB found in group #281 in a Level 30 area:
Warrior - Level 28
BOB - Level 60
Warrior - Level 28
Shaman - Level 32
Warrior - Level 32
Shaman - Level 29
Warrior - Level 28
Warrior - Level 30
Shaman - Level 29
Warrior - Level 30

Here is some of the code written to create this demo. Look at the Goblins and their override of rdsCreateInstance(). This will return a new Goblin to the result set, so each Monster contained is its own, living instance.

The Goblins are created for this demo as simple as possible:

C#
// The Goblin base class needs at least a level
// In a real game scenario you will likely have a 
// base class "Monster" or even "NPC", which will
// have the level. For this Demo, a Goblin is enough.
public class Goblin : RDSCreatableObject
{
 public Goblin(int level) { Level = level; }
 
 public int Level = 0;
 public override string ToString()
 {
  return this.GetType().Name + " - Level " + Level.ToString();
 }
}

The three Goblins derive from this class, they look all the same in this demo, so I just show the Shaman as a representative for all three:

C#
public class Shaman : Goblin
{
 public Shaman(int level) : base(level) { }
 public override IRDSObject rdsCreateInstance()
 {
  return new Shaman(Level);
 }
}

NEW in this demo is the GoblinTable class. We do not use RDSTable directly, we derive from it, add a custom constructor and add the entries in the derived table. Look at the different levels, probabilities and the extremely low chance for BOB, to appear.

C#
public class GoblinTable : RDSTable
{
 public GoblinTable(int arealevel)
 {
  // Shamans with different level based on the arealevel
  // With a probability curve peaked at the area level
  AddEntry(new Shaman(arealevel - 2), 100);
  AddEntry(new Shaman(arealevel - 1), 200);
  AddEntry(new Shaman(arealevel    ), 500);
  AddEntry(new Shaman(arealevel + 1), 200);
  AddEntry(new Shaman(arealevel + 2), 100);
  // Same for Warriors
  AddEntry(new Warrior(arealevel - 2), 100);
  AddEntry(new Warrior(arealevel - 1), 200);
  AddEntry(new Warrior(arealevel    ), 500);
  AddEntry(new Warrior(arealevel + 1), 200);
  AddEntry(new Warrior(arealevel + 2), 100);
  // BOB is double the arealevel - a real hard one!
  AddEntry(new BOB(arealevel * 2), 1);
  rdsCount = 10;
 }
}

I think, if not already happened so far, NOW you see some of the power and comfort, this library has to offer for your design of random content!

Demo 5 - Playing with RDSValue<T>. Random Gold Drops and Other Values

Finally. BOB is dead! What did he drop? How rich has he been really?

The imported part in this short demo is, that you derive a class from RDSValue<T> to contain a Gold drop. The value is calculated when it is constructed based on the constructor parameters AreaLevel, MobLevel and PlayerLevel.

The formula taken is: Base Gold amount is 10 * AreaLevel. Now add/subtract MonsterLevel-AreaLevel and AreaLevel-Playerlevel (to punish highlevel players in lowlevel areas). You could as well use some Random formula here, I just wanted to show the dynamic assignment of a value, as well as introducing the RDSValue<T> a bit.

C#
Enter Area Level: 20
Enter Monster Level: 22
Enter Player Level: 24
Querying Gold drop: 198,00

This is really a very short and simple demo, only to show the access to a RDSValue<T> object and what you can do with it. Play around with some RDSValues, I am sure you will find a lot of usage scenarios.

C#
// This table contains only 1 entry to demonstrate the RDSValue<T> class.
// In a real scenario, a gold drop is only one of many entries for the loot
// of a mob of course.
RDSTable gold = new RDSTable();
gold.AddEntry(new GoldDrop(baselevel, moblevel, playerlevel), 1);
Console.WriteLine("Querying Gold drop: " + 
     ((GoldDrop)gold.rdsResult.First()).rdsValue.ToString("n2"));
public class GoldDrop : RDSValue<double>
{
 public GoldDrop(int arealevel, int moblevel, int playerlevel):
  base(0, 1)
 {
  rdsValue = 10 * arealevel + (moblevel - arealevel) + (arealevel - playerlevel);
 }
}

Demo 6 - Random Generating a Simple Map

Short demo of selecting 25 map pieces randomly to create a 5x5 map. You can create any map size with this system, of course.

The setup here is to demonstrate a new technique: Dynamically enabling and disabling entries of one single table in the PreResult override, based on the exits a map segment has.

We create a class named MiniDungeon : RDSTable. This table contains lots of MapSegment objects, that derive from RDSObject. Each Segment has four exits: North, East, South and West. Those boolean flags represent the possible exits of a Segment and are used to modify the states of the contents of the MiniDungeon.

MapSegment gets a constructor that takes four boolean parameters, each one describing one of the possible exits. We want to loot only Segments, that can fulfill the needs of the map (i.e., have the desired exits).

In the PreResult override, each MapSegment disables/enables itself based on the requested exits, so that only those Segments stay active that can fulfill the desired exits.

The algorithm of the Map is clearly not the most high sophisticated you have ever seen, but that's not the point of the demo. A demo output of a 5x5 map could look like this, in simple semigraphic console output:

███████ ████ ████████████
███████ ████ ████████████
██      ██
██ ████ ████ ████ ████ ██
██ ████ ████ ████ ████ ██
██ ████ ████ ████ ████ ██
██ ████ ████ ████ ████ ██
        ████           ██
██ ████ █████████ ███████
██ ████ █████████ ███████
██ ████ █████████ ███████
██ ████ █████████ ███████
                  ██
███████ ████ ████ ████ ██
███████ ████ ████ ████ ██
██ ████ ████ ████ ████ ██
██ ████ ████ ████ ████ ██
██           ██        ██
██ █████████ ████ ████ ██
██ █████████ ████ ████ ██
██ ████ ████ ████ ████ ██
██ ████ ████ ████ ████ ██
                  ██   ██
██ ███████████████████ ██
██ ███████████████████ ██

That's enough for a few lines of code and to give you a base for further experiments. Let's take a closer look at the MiniDungeon class and how this all works:

I have set up this table containing every possible combination of the 4 exits, except the 0000 (no exit). This leaves us with 15 entries in the table, all with the same probability for simplicity:

C#
public class MiniDungeon : RDSTable
{
 public MiniDungeon()
 {
  // Add all possible combinations of exits except the 0000 (no exit)
  // All have the same probability for this demo, in a real scenario
  // you could and probably will make some combinations 
  // more rare than others of course or have more different segments
  // with the same exits... don't forget, this is just a demo!
  AddEntry(new MapSegment(false, false, false, true ), 10);
  AddEntry(new MapSegment(false, false, true , false), 10);
  AddEntry(new MapSegment(false, false, true , true ), 10);
  AddEntry(new MapSegment(false, true , false, false), 10);
  AddEntry(new MapSegment(false, true , false, true ), 10);
  AddEntry(new MapSegment(false, true , true , false), 10);
  AddEntry(new MapSegment(false, true , true , true ), 10);
  AddEntry(new MapSegment(true , false, false, false), 10);
  AddEntry(new MapSegment(true , false, false, true ), 10);
  AddEntry(new MapSegment(true , false, true , false), 10);
  AddEntry(new MapSegment(true , false, true , true ), 10);
  AddEntry(new MapSegment(true , true , false, false), 10);
  AddEntry(new MapSegment(true , true , false, true ), 10);
  AddEntry(new MapSegment(true , true , true , false), 10);
  AddEntry(new MapSegment(true , true , true , true ), 10);
  rdsCount = 1;
 }
...
...
...

A MapSegment is very simple in its design, too:

C#
public class MapSegment : RDSObject
{
 public MapSegment(bool exitnorth, bool exiteast, bool exitsouth, bool exitwest)
 {
  North = exitnorth;
  East = exiteast;
  South = exitsouth;
  West = exitwest;
 }
 public bool North = false;
 public bool East = false;
 public bool South = false;
 public bool West = false;
 public override void OnRDSPreResultEvaluation(EventArgs e)
 {
  base.OnRDSPreResultEvaluation(e);
  // Look up what our table needs
  // Every RDSObject has a pointer to the table where it is contained
  MiniDungeon t = rdsTable as MiniDungeon;
  rdsEnabled = ((t.NeedEast && East) || !t.NeedEast) &&
   ((t.NeedWest && West) || !t.NeedWest) &&
   ((t.NeedNorth && North) || !t.NeedNorth) &&
   ((t.NeedSouth && South) || !t.NeedSouth);
 }
 ...
 ...
 ...

The demo algorithm focuses on the exits of a neighbor field to determine, what elements are allowed to drop for the next field. Take a close look at the override OnRDSPreResultEvaluation method:

  • First new thing here: Each RDSObject has a pointer to the table where it is contained, the rdsTable field. It is set by the AddEntry method of a RDSTable object. You can use this field to get runtime data from the table, in this case, what exits are needed for the next field.
  • The MapSegment sets its own rdsEnabled property based on the needed exits and the exits it self can support. If this results in false, this Segment can not drop. It's as easy as that.

The MiniDungeon class now got a method GenerateMap(..,..) that plays around with the boolean flags of needed exits based on the position of the generation where it currently is. In the top row, only a South exit is really needed, same as in the most left or right column we need an East or West exit, and for all the fields in the middle of the map, NeedNorth and NeedWest are set based on the exits of the neighbour fields, so we get Segments that fit with their neighbors.

C#
// Generates a random map with a given dimension
public MapSegment[,] GenerateMap(int sizeX, int sizeY)
{
 MapSegment[,] map = new MapSegment[sizeX, sizeY];
 for (int y = 0; y < sizeY; y++)
 {
  for (int x = 0; x < sizeX; x++)
  {
   if (y == 0)
   {
    NeedNorth = false;
    NeedSouth = true;
   }
   else if (y == sizeY - 1)
   {
    NeedNorth = true;
    NeedSouth = false;
   }
   else
   {
    NeedNorth = (map[x, y - 1].South);
    NeedSouth = !NeedNorth;
   }
   if (x == 0)
   {
    NeedEast = true;
    NeedWest = false;
   }
   else if (x == sizeX - 1)
   {
    NeedEast = false;
    NeedWest = true;
   }
   else
   {
    NeedWest = (map[x - 1, y].East);
    NeedEast = !NeedWest;
   }
   map[x, y] = (MapSegment)rdsResult.First();
  }
 }
 return map;
}

Again: This is a very simple and far from perfect algorithm and I honestly don't think it can be used in its current state for any real game. But I also think, it is enough of base work to get you on track and to make you see, what is possible with RDS.

It's the same scheme every time, for every random content. No matter if you have a drop system like Diablo (where a ZOD rune drops only once in a zillion of drops), or you want to generate maps, spawn Monsters at random positions and random amount, for everything you want to create dynamically.

I hope you have now a good idea of what RDS can do for you. I think it is a library with very high value that takes away lots of decision work from you if you agree to really implement (inherit) the RDS classes. It all works together fine and you have almost every thinkable freedom with lots of virtual methods to override.

I hope you have fun with this library,

Yours,

Mike.

History

  • 2012-07-13: First draft started

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
Member 1124143917-Nov-14 9:11
Member 1124143917-Nov-14 9:11 
QuestionResetResult Pin
Lazerath7-Jan-14 7:49
Lazerath7-Jan-14 7:49 
AnswerRe: ResetResult Pin
Mike (Prof. Chuck)7-Jan-14 18:39
professionalMike (Prof. Chuck)7-Jan-14 18:39 
GeneralMy Vote of 5 Pin
Vanzanz4-Dec-13 6:17
Vanzanz4-Dec-13 6:17 
GeneralRe: My Vote of 5 Pin
Mike (Prof. Chuck)4-Dec-13 20:35
professionalMike (Prof. Chuck)4-Dec-13 20:35 
QuestionGreat Job Pin
Jeremy Airey20-Nov-13 10:48
professionalJeremy Airey20-Nov-13 10:48 
AnswerRe: Great Job Pin
Mike (Prof. Chuck)4-Dec-13 20:34
professionalMike (Prof. Chuck)4-Dec-13 20:34 
QuestionGood Work Pin
MrWiggels9-Oct-12 8:23
MrWiggels9-Oct-12 8:23 
AnswerRe: Good Work Pin
Mike (Prof. Chuck)9-Oct-12 20:30
professionalMike (Prof. Chuck)9-Oct-12 20:30 
QuestionRealistic Drops Pin
Chad3F8-Oct-12 16:21
Chad3F8-Oct-12 16:21 
AnswerRe: Realistic Drops Pin
Mike (Prof. Chuck)9-Oct-12 0:59
professionalMike (Prof. Chuck)9-Oct-12 0:59 
GeneralVery nicely Done! Pin
Member 402345417-Jul-12 5:20
Member 402345417-Jul-12 5:20 
Some good work here! Thanks for sharing Smile | :)

Can you supply a link to the zip file?

Thanks
GeneralRe: Very nicely Done! Pin
Mike (Prof. Chuck)17-Jul-12 7:57
professionalMike (Prof. Chuck)17-Jul-12 7:57 
GeneralMy vote of 5 Pin
Member 844697316-Jul-12 5:00
Member 844697316-Jul-12 5:00 
GeneralRe: My vote of 5 Pin
Mike (Prof. Chuck)16-Jul-12 18:54
professionalMike (Prof. Chuck)16-Jul-12 18:54 

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.