 |
As of this writing, the 4K game programming contest has experienced four years of successful operation. It has challenged programmers both new and old to develop new ideas and new ways of looking at things all so they can keep their game under 4K in size. However, as the competition has expanded with newer and ever cooler entries, it has made life more and more difficult for new entrants to compete.
This article will attempt to share some of the knowledge that has been used by the competitors to keep their games small. With a little understanding, you too can be cranking out small and enjoyable games in no time flat!
General Tips
- Keep your code down to one class. Each class adds the overhead of an entry in the JAR file, as well as a brand new constant pool and class list.
- Keep your methods to a minimum. Each method adds overhead in the class file. All you should need is a main() method, and methods to implement the keyboard and/or mouse routines.
- Don't use global variables. Global variables require special meta-data in the class to identify. Method-local variables, however, are only stack entries and cost nothing extra to use.
- Use a good compressor like 7Zip or KZip to create your JAR files. The JAR utility is mostly designed for correctness, not compression ratios.
- Use an obfuscator like ProGuard, JoGa, or JShrink to optimize the size of your class.
- Use a single character for the class file name. This reduces its size internally, reduces the amount of info the Zip program stores, and reduces the size of the manifest.
- Reference as few classes as possible. Each class you reference adds the full package and class name, plus the method signature you're calling.
- Redundancy (such as using the same name for all your methods and classes and fields) improves compression ratios.
- Methods made private and final can be inlined by a class optimizer.
- Use the String.valueOf() method to convert primitives to strings. For example, ""+number expands to: new StringBuffer?().append("").append(number).toString() wasting a great deal of space in new class and method references.
- Static strings, floats, and integers used in the source code get stored in the constant pool. As a result, the more you can reuse a static value, the smaller your class will be.
- You can make liberal use of static final varaibles for constants. This will make your code more readable and ProGuard will optimize this away so there is no extra overhead.
Manifest
In the past, it was considered acceptable to produce a JAR file that was not executable. As of the 2006 competition, all JAR files must be self-executable unless they are Applets or Webstart files. However, if you allow the JAR utility to create the manifest, it will chew up your available space with miscellaneous information. The solution to this is to create a custom Manifest file. Simply create a file with the following lines:
Main-Class: MyClass
Note that there are precisely two carriage returns after the Main-Class line. These are required by the 1.5 JVM. Without them, the JVM will complain that the file is not executable.
Once you have created the file, ZIP it into your JAR file as "META-INF/MANIFEST.INF". The JVM will recognize it as the manifest and execute the class you specified.
Data Structures
In C/C++ and assembler games of old, it was very easy to create data structures for your games just by using a "STRUCT" datatype. The Java replacement for C style structs is class files themselves. Unfortunately, class files are a no-no in 4K. Especially if they're inner classes.
The solution to this problem is to use arrays for each data type you need to store. For example, a class to store a bad guy might look like this:
public class BadGuy
{
public int x;
public int y;
public int type;
}
To convert that to arrays, you first need to decide what the maximum number of values you're going to use is. I prefer to use 256. Thus code using this trick might look like this:
public void main(String[] args)
{
int[] badguy_x = new int[256];
int[] badguy_y = new int[256];
int[] badguy_type = new int[256];
}
Usually the "type" array is setup so that zero means 'dead' and non-zero gives the type of an alive creation. A further optimization is to ensure that "type" numbers match up with the graphics in an array.
Graphics
There are currently three major methods used for graphics:
- Procedural Graphics
- Tight PNGs or GIFs
- SuperPackME?
Procedural graphics involves using combinations of the line, oval, and rectangle functions to produce a sprite. An alternative is to dynamically create a bitmap pixel by pixel. The advantage to this method is that you don't need to worry about loading graphics. You can draw as needed, and even scale the graphics with no ill effects. The con to this approach is that it often increases the number of class and method references.
Tight PNGs or GIFs involves storing your images in a grid inside a single, highly compressed image file. That image file is usually run through a program like PNGCrush to ensure that it is as small as possible. After the image had been optimized, it's renamed to a single letter without the extension and added to the JAR archive. The graphic is loaded via the ImageIO classes at runtime. The ImageIO classes auto-detect the file type, thus allowing the developer to save space by not saving the file extension. To draw an individual image, you simply call the Graphics.drawImage(image, x1, y1, width1, height1, x2, y2, width2, height2, ImageObserver?) method with the correct coordinates.
SuperPackME? is a highly sophisticated approach to storing graphics. It involves stripping the image data down to a super-tight, highly compressable data format. That data format is then converted to a string of hexadecimal digits, then embedded into the class file itself. Once decoded, the developer will have access to an array of all his original images with no loss in quality.
Compression
Many programmers think that the key to fitting a game into 4K is to make the class file smaller. More than one programmer has been shocked when their final JAR becomes larger after the class file is optimized.
It's important to realize that the compressor is one of the keys to a successful 4K game. You must work with the compressor, not against it. This means that you should always choose to make information as redundant as possible. Try to write code that will produce as many redundant byte codes as possible. Also be aware that the ZIP compressor starts over for each entry in the ZIP/JAR file. So try to inline your information into strings rather than incur the massive overhead of multiple files.
7Zip or KZip will always produce better compression than using the standard JAR tool. Use the Manifest instructions above to create a manifest file, then follow the instructions below.
To create a JAR with 7Zip, use the following command:
7z a -tzip -mx=9 <NAME>.jar <NAME>.class META-INF/MANIFEST.MF
There are two caveats with 7Zip that you should be aware of. The first is that the existing JAR file should always be deleted before you recreate it. Reusing an existing file often results in poorer compression. The second caveat is that you MUST use the command line tool. The graphical user interface never produces files as small as the files produced by the command line version.
To create a JAR with KZip, use the following command:
kzip /s0 /r <NAME>.jar <NAME>.class META-INF/MANIFEST.MF
? There may be more options on other operating systems. This worked great in linux and in one instance gave me 170 more bytes to work with than 7zip was offering.
Sound
[WIP]
Keyboard
Don't implement listeners to read input. Use the processXXXEvent methods instead. It is a lot smaller and cleaner.
import java.awt.AWTEvent;
import java.awt.event.KeyEvent;
import javax.swing.JFrame;
public class T extends JFrame {
static boolean keys[] = new boolean[65536];
public static void main(String args[]) {
T t = new T();
t.enableEvents(AWTEvent.KEY_EVENT_MASK);
t.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
t.setBounds(0, 0, 800, 600);
t.setResizable(false);
t.show();
while(true) {
if(keys[KeyEvent.VK_A] || keys[KeyEvent.VK_LEFT]) {
System.out.println("LEFT");
}
if(keys[KeyEvent.VK_D] || keys[KeyEvent.VK_RIGHT]) {
System.out.println("RIGHT");
}
if(keys[KeyEvent.VK_W] || keys[KeyEvent.VK_UP]) {
System.out.println("UP");
}
if(keys[KeyEvent.VK_S] || keys[KeyEvent.VK_DOWN]) {
System.out.println("DOWN");
}
if(keys[KeyEvent.VK_CONTROL] || keys[KeyEvent.VK_SPACE]) {
System.out.println("FIRE");
}
if(keys[KeyEvent.VK_ESCAPE]) {
System.exit(0);
}
}
}
public void processKeyEvent(KeyEvent e) {
keys[e.getKeyCode()] = (e.getID() == KeyEvent.KEY_PRESSED);
}
}
Miscellaneous Ideas
- Try to push values between -128 and 127 to the stack, you can lower your values and multiply it back. (very simple example: 100+200-300-400+500 takes more bytes than (10+20-30-40+50)*10, so push tiny values if possible. )
- If you have an int array of some small values, try to put everything in a long, and retreive your values with bit-shifting.
- If you are using fields a lot inside your methods, it will save you quite a few bytes when you make a local field pointing at the field: (I tried this with two double arrays in my game and the size went UP by 15 bytes - SWP)
public class X
{
private int x;
public final void x()
{
int x = this.x;
// do a lot with x
this.x = x;
}
private void X(int a); // sinks the ship
private void X(float a); // lands the plane
private void X(int[] a); // make histogram of scattered ants
}
(!) Anything denoted with an exclamation point is a hack that is potentially unsafe. Use with caution!
(?) This needs to be clarified by someone.
|