Build a playable four-lane rhythm game with Java’s built-in Swing and Java 2D APIs: notes move toward a judgment line, D/F/J/K trigger lanes, and each press earns a Perfect, Good, or Miss result. The key is to keep note timing tied to elapsed time—not to how often the screen happens to update.
This is a learning project, not a production rhythm engine. It needs a JDK and a desktop environment that can open a Swing window; no third-party library is required.
As an Amazon Associate I earn from qualifying purchases.
What you’ll build
The first version has four lanes, tap notes, a fixed judgment line, a small chart, score, combo, and three judgments. Its parts map neatly to Java fundamentals:
Quick wins for a faster PC:
Clear out junk files and repair common Windows errorsFree Scan →Scan for outdated or missing drivers - takes under a minuteDriver Scan →| Game feature | Java concept |
|---|---|
| A note | Class and fields |
| Chart | List or array |
| Updates | Swing timer and callbacks |
| Drawing | Inheritance and paintComponent |
| Lane controls | Key bindings and actions |
| Hit detection | Conditionals and absolute differences |
| Score and game state | Mutable state and enums |
| Restart | Resetting object state |
Keep the first build narrow. Long notes, multiple songs, BPM changes, menus, persistence, and exact music synchronization can wait until this version works.
Set up and launch the window
Use JFrame as the top-level window and a custom JPanel as the game surface. Swing and Java 2D are included in Java SE’s desktop APIs; Oracle’s desktop overview describes the available desktop technologies.
Create a file named RhythmGame.java. The launcher should create Swing components on the event-dispatching thread:
import javax.swing.JFrame;
import javax.swing.SwingUtilities;
public class RhythmGame {
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
JFrame frame = new JFrame("Simple Rhythm Game");
GamePanel panel = new GamePanel();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setContentPane(panel);
frame.pack();
frame.setLocationRelativeTo(null);
frame.setResizable(false);
frame.setVisible(true);
panel.startGame();
});
}
}
At this point, GamePanel is the panel you will implement next. A JFrame is Swing’s top-level window. Swing components should be created and updated in accordance with Swing’s threading model.
Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchPC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11Compile and run from the directory containing the source file:
javac RhythmGame.java
java RhythmGame
A JDK provides the compiler as well as the runtime. If you use a named package instead, compile to an output directory and run using the fully qualified class name, for example javac -d out src/com/example/rhythm/RhythmGame.java followed by java -cp out com.example.rhythm.RhythmGame.
Represent notes and chart times
Give each note a zero-based lane, a scheduled time in milliseconds from the start of the round, and a flag that prevents it from being judged twice:
Rank #2
import java.util.List;
final class Note {
final int lane;
final long hitTimeMs;
boolean judged;
Note(int lane, long hitTimeMs) {
this.lane = lane;
this.hitTimeMs = hitTimeMs;
}
}
List<Note> notes = List.of(
new Note(0, 1000),
new Note(1, 1500),
new Note(2, 2000),
new Note(3, 2500),
new Note(0, 3000),
new Note(2, 3500)
);
The times above are example chart data, not measured song timings. Keep the list sorted by hitTimeMs; the input algorithm later relies on that ordering. A chart is timing data, not screen coordinates, so the same chart can be drawn at a different panel size or note speed.
For the first build, hard-code the list. A later plain-text CSV chart could use rows such as lane,timeMs and 0,1000. JSON generally needs a parser library, so it adds little value to the first playable version.
Use elapsed time as the game clock
A Swing timer asks the game to update and repaint, but callbacks are not guaranteed to arrive at an exact interval. Keep those ideas separate: the timer schedules work; a monotonic clock determines where each note belongs. Java’s Swing timer documentation notes that timer actions run on the event-dispatching thread, so handlers should stay short.
Record a start time when a round begins and derive elapsed milliseconds from System.nanoTime() on each update:
long startNanos;
long songTimeMs;
private void updateGame() {
if (state != GameState.PLAYING) {
return;
}
songTimeMs = (System.nanoTime() - startNanos) / 1_000_000L;
markMissedNotes();
}
Use long for elapsed time. Avoid moving a note with a fixed instruction such as note.y += 5: if callbacks stall or vary, movement speed varies too. Instead, calculate its screen position from its chart time:
private static final int HIT_LINE_Y = 500;
private static final double NOTE_SPEED = 0.35; // pixels per millisecond
double distance = (note.hitTimeMs - songTimeMs) * NOTE_SPEED;
int y = (int) (HIT_LINE_Y - distance);
Before its scheduled time, a note is above the line; at that time, it reaches the line; afterward, it passes below. The 0.35 pixels-per-millisecond value is a tunable example, not a standard. A 16 ms timer requests updates roughly every 16 ms; it does not guarantee 60 updates per second.
Build the game panel and update timer
Keep the initial implementation in one file if that helps you get a result sooner. Once it works, separate responsibilities into RhythmGame (launcher), GamePanel (game state and drawing), Note (note data), Chart (song notes), and optionally SoundManager (audio).
In GamePanel, set a preferred size, make the panel focusable, and create a Swing timer. For example:
enum GameState {
READY,
PLAYING,
FINISHED
}
private static final int PANEL_WIDTH = 800;
private static final int PANEL_HEIGHT = 600;
private static final int LANE_COUNT = 4;
private GameState state = GameState.READY;
private long startNanos;
private long songTimeMs;
private int score;
private int combo;
private int maxCombo;
private String lastJudgment = "";
private final Timer timer = new Timer(16, e -> {
updateGame();
repaint();
});
In the panel constructor, use setPreferredSize(new Dimension(PANEL_WIDTH, PANEL_HEIGHT)) and setFocusable(true). The JPanel API describes the panel as a lightweight Swing container. Keep timer work brief: do not load files, decode audio, or perform expensive calculations there.
Recommended Free Tools
Draw lanes, notes, and the judgment line
Override paintComponent, call the superclass implementation first, then draw the current game state. Oracle’s Swing painting overview explains this custom-painting approach and Swing’s built-in double buffering.
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setColor(Color.BLACK);
g2.fillRect(0, 0, getWidth(), getHeight());
drawLanes(g2);
drawNotes(g2);
drawHud(g2);
} finally {
g2.dispose();
}
}
Divide the current panel width into four lanes rather than assuming the window can never change size:
private void drawLanes(Graphics2D g2) {
int laneWidth = getWidth() / LANE_COUNT;
for (int lane = 0; lane < LANE_COUNT; lane++) {
int x = lane * laneWidth;
g2.setColor(lane % 2 == 0
? new Color(35, 35, 45)
: new Color(50, 50, 60));
g2.fillRect(x, 0, laneWidth, getHeight());
g2.setColor(Color.DARK_GRAY);
g2.drawRect(x, 0, laneWidth, getHeight());
}
g2.setColor(Color.WHITE);
g2.fillRect(0, HIT_LINE_Y, getWidth(), 4);
}
Draw only unjudged notes that are near the visible area:
Rank #4
private void drawNotes(Graphics2D g2) {
int laneWidth = getWidth() / LANE_COUNT;
for (Note note : notes) {
if (note.judged) {
continue;
}
int y = (int) (HIT_LINE_Y
- (note.hitTimeMs - songTimeMs) * NOTE_SPEED);
if (y < -40 || y > getHeight() + 40) {
continue;
}
int x = note.lane * laneWidth + 12;
int width = laneWidth - 24;
g2.setColor(Color.CYAN);
g2.fillRoundRect(x, y - 12, width, 24, 10, 10);
}
}
Implement drawHud to display the score, combo, most recent judgment, and the mapping D / F / J / K to lanes 1–4. Avoid creating large numbers of objects or doing slow work in painting.
Free tools Windows power users keep installed
One-click scans. No signup required.
Bind lane keys and judge the nearest note
Swing key bindings connect a keystroke to an action through an InputMap and ActionMap; they are usually a better fit than a raw KeyListener for this panel because they are less dependent on one component receiving keyboard focus. See the JComponent key-binding API.
private void bindKey(String key, int lane) {
InputMap inputMap = getInputMap(
JComponent.WHEN_IN_FOCUSED_WINDOW);
ActionMap actionMap = getActionMap();
inputMap.put(KeyStroke.getKeyStroke(key), "lane-" + lane);
actionMap.put("lane-" + lane, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
judgeLane(lane);
}
});
}
// Call in the GamePanel constructor:
bindKey("D", 0);
bindKey("F", 1);
bindKey("J", 2);
bindKey("K", 3);
Use configurable judgment windows. These 60 ms and 140 ms values are example design choices, not universal rhythm-game standards:
private static final long PERFECT_WINDOW_MS = 60;
private static final long GOOD_WINDOW_MS = 140;
private static final long MISS_WINDOW_MS = 180;
On a press, find the closest unjudged note in that lane that is inside the Good window. Never select an arbitrary note: otherwise an early press might hit a later note. Since the chart is sorted, the loop can stop once it reaches a note too far in the future:
private void judgeLane(int lane) {
if (state != GameState.PLAYING) {
return;
}
for (Note note : notes) {
if (note.judged || note.lane != lane) {
continue;
}
long difference = Math.abs(note.hitTimeMs - songTimeMs);
if (difference <= GOOD_WINDOW_MS) {
boolean perfect = difference <= PERFECT_WINDOW_MS;
judge(note, perfect ? "PERFECT" : "GOOD",
perfect ? 1000 : 500);
return;
}
if (note.hitTimeMs > songTimeMs + GOOD_WINDOW_MS) {
break;
}
}
}
Update the logical clock before handling presses, for example in the timer update and at the beginning of judgeLane, so input is compared against current elapsed time rather than a stale previous tick. A press with no eligible note does nothing. These example score values are deliberately simple.
Do these 3 things before closing this tab:
1Scan for outdated or missing drivers - takes under a minute2Repair Windows errors before they cause bigger problems3Fix the driver behind crashes, sound loss and screen glitchesUpdate score and combo once per judged note:
private void judge(Note note, String result, int points) {
note.judged = true;
score += points;
combo++;
maxCombo = Math.max(maxCombo, combo);
lastJudgment = result;
}
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Mark misses and finish the round
A note that passes its miss window must be marked once. If it is left unjudged, every timer callback could reset combo again:
Best Value
private void markMissedNotes() {
for (Note note : notes) {
if (!note.judged
&& songTimeMs > note.hitTimeMs + MISS_WINDOW_MS) {
note.judged = true;
combo = 0;
lastJudgment = "MISS";
}
}
}
After marking misses, test whether every chart note is judged. If so, switch to FINISHED and stop the timer. The 180 ms miss window above is another tunable example, separate from the Perfect and Good windows.
Add a reliable start and restart
Reset all mutable round data before starting. Setting the start timestamp before switching to PLAYING avoids calculating elapsed time from an uninitialized clock.
private void startGame() {
timer.stop();
score = 0;
combo = 0;
maxCombo = 0;
lastJudgment = "";
songTimeMs = 0;
for (Note note : notes) {
note.judged = false;
}
startNanos = System.nanoTime();
state = GameState.PLAYING;
timer.start();
}
Call this method after the frame is visible for the first round, and bind a restart action when the state is FINISHED. Stopping an already-running timer before reset prevents an old round’s updates from continuing through a restart. A chart with no notes should finish immediately or be rejected as empty.
Debug the first playable version
| Symptom | Likely cause | What to check |
|---|---|---|
| Blank window | The panel is not being painted or another drawing step obscures it. | Set the panel as the content pane, override paintComponent, and call super.paintComponent(g). |
| Keys do nothing | The window lacks focus, the binding is on the wrong component, or a control has taken focus. | Use WHEN_IN_FOCUSED_WINDOW; check the key names and visible mapping. If needed, call requestFocusInWindow() after the window is visible. Avoid a text field or button stealing focus during play. |
| Notes move inconsistently | Position is based on callback count. | Calculate Y from elapsed milliseconds and chart time. |
| Every note is missed | Chart times and game clock use different units or origins. | Use milliseconds from round start for both. |
| Notes score more than once | The note is not marked judged immediately. | Set judged in the same operation that records its result. |
| Later note hits when pressing early | Candidate selection chose any note in the lane. | Choose the closest eligible note and keep the chart sorted. |
| Window freezes | Timer action does lengthy work on Swing’s event-dispatching thread. | Keep updates short and move file or audio loading out of the callback. |
Other edge cases to handle as you extend the game include duplicate timestamps, overlapping windows for same-lane notes, notes outside the visible play area, a long pause or computer sleep causing a time jump, and holding a key. Treat each key action as a press, not as a continuous hit; if repeated key events are a problem, track press/release state. If the window loses focus, input may stop even while the timer continues.
Add sound only after gameplay works
Java Sound is part of Java SE’s desktop technology set. Start with a short preloaded hit effect, and keep the chart clock independent of playback. A basic Clip.start() call does not make full-song playback sample-synchronized with chart timing.
If a WAV file is packaged as a classpath resource such as /hit.wav, a basic loader can look like this:
try (AudioInputStream input = AudioSystem.getAudioInputStream(
RhythmGame.class.getResource("/hit.wav"))) {
Clip clip = AudioSystem.getClip();
clip.open(input);
clip.start();
} catch (UnsupportedAudioFileException
| IOException
| LineUnavailableException ex) {
ex.printStackTrace();
}
Use a packaged resource rather than a machine-specific path such as C:UsersNameDesktophit.wav. For a real project, also manage clip lifetime and loading failures deliberately. Full tracks, offset calibration, and audiovisual synchronization are a separate, more advanced problem.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
When to move beyond Swing
Swing exposes window creation, painting, input, and timing without a third-party engine or build-system setup, making it suitable for a small educational 2D prototype. It is not a dedicated game framework, and its timer should not be treated as a high-precision audio clock. A dedicated game loop can offer more simulation control, but adds thread-management and shutdown complexity; graduate to one when the project’s needs justify that cost.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




