John Roest

Developing a Simple Game in Plain Java with Swing: My Journey So Far

Developing a Simple Game in Plain Java with Swing: My Journey So Far

I recently started building a small game in plain Java with Swing—no game engine, no external framework. The goal was to understand the mechanics of a game loop from the ground up. So far, the result is a player sprite that moves around a black screen at a stable 60 FPS, with the FPS counter displayed on screen.

Setting Up the Game Window#

The GamePanel class is the central component. It handles rendering, keyboard input, and the game loop:

package main;

import entity.Player;

import javax.swing.*;
import java.awt.*;

public class GamePanel extends JPanel implements Runnable {
    // SCREEN SETTINGS
    final int originalTileSize = 16; // 16x16 tile
    final int scale = 3;

    public final int tileSize = originalTileSize * scale; // 48x48 tile
    final int maxScreenCol = 16;
    final int maxScreenRow = 12;
    final int screenWidth = tileSize * maxScreenCol; // 768 pixels
    final int screenHeight = tileSize * maxScreenRow; // 576 pixels

    int FPS = 60;

    KeyHandler keyHandler = new KeyHandler();
    Thread gameThread;
    Player player = new Player(this, keyHandler);

    int globalFPS = 0;

    public GamePanel() {
        this.setPreferredSize(new Dimension(screenWidth, screenHeight));
        this.setBackground(Color.BLACK);
        this.setDoubleBuffered(true);
        this.addKeyListener(keyHandler);
        this.setFocusable(true);
    }

    public void startGameThread() {
        gameThread = new Thread(this);
        gameThread.start();
    }

    @Override
    public void run() {
        double drawInterval = 1_000_000_000 / FPS; // 0.016666 seconds per frame
        double delta = 0;
        long lastTime = System.nanoTime();
        long currentTime;
        long timer = 0;
        int drawCount = 0;

        while (gameThread != null) {
            currentTime = System.nanoTime();

            delta += (currentTime - lastTime) / drawInterval;
            timer += (currentTime - lastTime);
            lastTime = currentTime;

            if (delta >= 1) {
                update();
                repaint();
                delta--;
                drawCount++;
            }

            if (timer >= 1_000_000_000) {
                System.out.println("FPS: " + drawCount);
                globalFPS = drawCount;
                drawCount = 0;
                timer = 0;
            }
        }
    }

    public void update() {
        player.update();
    }

    @Override
    public void paintComponent(Graphics g) {
        super.paintComponent(g);

        Graphics2D g2d = (Graphics2D) g;
        player.draw(g2d);

        // Draw FPS on screen
        g2d.setColor(Color.WHITE);
        g2d.drawString("FPS: " + globalFPS, 10, 20);

        g2d.dispose();
    }
}

The screen is 768x576 pixels, based on a 48x48 tile grid (16 columns, 12 rows). The game loop uses nanosecond timing to accumulate a delta value, updating and repainting the screen once the delta reaches 1 full frame interval. This approach decouples logic updates from wall clock time, keeping frame rate consistent across machines.

The Player Entity#

The Player class handles movement and sprite rendering:

package entity;

import main.GamePanel;
import main.KeyHandler;

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;

public class Player extends Entity {

    GamePanel gp;
    KeyHandler keyHandler;

    public Player(GamePanel gp, KeyHandler keyHandler) {
        this.gp = gp;
        this.keyHandler = keyHandler;

        setDefaultValues();
    }

    public void setDefaultValues() {
        x = 100;
        y = 100;
        speed = 4;
        direction = EntityDirection.down;
        getPlayerImage();
    }

    public void getPlayerImage() {
        try {
            up1 = ImageIO.read(getClass().getClassLoader().getResourceAsStream("player/boy_up_1.png"));
            up2 = ImageIO.read(getClass().getClassLoader().getResourceAsStream("player/boy_up_2.png"));
            down1 = ImageIO.read(getClass().getClassLoader().getResourceAsStream("player/boy_down_1.png"));
            down2 = ImageIO.read(getClass().getClassLoader().getResourceAsStream("player/boy_down_2.png"));
            left1 = ImageIO.read(getClass().getClassLoader().getResourceAsStream("player/boy_left_1.png"));
            left2 = ImageIO.read(getClass().getClassLoader().getResourceAsStream("player/boy_left_2.png"));
            right1 = ImageIO.read(getClass().getClassLoader().getResourceAsStream("player/boy_right_1.png"));
            right2 = ImageIO.read(getClass().getClassLoader().getResourceAsStream("player/boy_right_2.png"));
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    public void update() {
        if(keyHandler.upPressed) {
            direction = EntityDirection.up;
            y -= speed;
        }

        if(keyHandler.downPressed) {
            direction = EntityDirection.down;
            y += speed;
        }

        if(keyHandler.leftPressed) {
            direction = EntityDirection.left;
            x -= speed;
        }

        if(keyHandler.rightPressed) {
            direction = EntityDirection.right;
            x += speed;
        }

        spriteCounter++;
        if(spriteCounter > 10) {
            if(spriteNum == 1) {
                spriteNum = 2;
            } else if (spriteNum == 2) {
                spriteNum = 1;
            }
            spriteCounter = 0;
        }
    }

    public void draw(Graphics2D g2d) {
        BufferedImage image = null;
        switch (direction) {
            case up -> {
                if (spriteNum == 1) {
                    image = up1;
                }
                if (spriteNum == 2) {
                    image = up2;
                }
            }
            case down -> {
                if (spriteNum == 1) {
                    image = down1;
                }
                if (spriteNum == 2) {
                    image = down2;
                }
            }
            case left -> {
                if (spriteNum == 1) {
                    image = left1;
                }
                if (spriteNum == 2) {
                    image = left2;
                }
            }
            case right -> {
                if (spriteNum == 1) {
                    image = right1;
                }
                if (spriteNum == 2) {
                    image = right2;
                }
            }
        }

        g2d.drawImage(image, x, y, gp.tileSize, gp.tileSize, null);
    }

}

Direction is represented as an enum. Movement updates position by speed pixels per frame in the pressed direction. Animation is handled by toggling between two sprite images every 10 frames using a frame counter.

Rendering and FPS Counter#

The paintComponent method draws the player sprite and overlays the FPS count:

@Override
public void paintComponent(Graphics g) {
    super.paintComponent(g);

    Graphics2D g2d = (Graphics2D) g;
    player.draw(g2d);

    // Draw FPS on screen
    g2d.setColor(Color.WHITE);
    g2d.drawString("FPS: " + globalFPS, 10, 20);

    g2d.dispose();
}

Graphics2D provides better rendering control than the base Graphics class. The globalFPS counter is updated once per second in the game loop and displayed each frame.

What Is Next#

The foundation is in place: a game loop, a moveable entity, and basic animation. The next steps are collision detection, additional entities (obstacles, enemies), and a tile-based map to replace the empty black background.

Building this from scratch in Java without a framework exposes the details that game engines handle automatically. That is the point. Understanding the underlying mechanics makes it easier to reason about higher-level abstractions when they are eventually needed.