Replay Viewer - Osu
Developing a feature for an osu! replay viewer —whether you are contributing to the official client, a third-party tool like
, or building your own—involves enhancing the way players analyze and share their gameplay. Core Feature Ideas
Based on community discussions and current project gaps, here are several high-impact features you could develop:
Replay analyzer improvements · ppy osu · Discussion #31558 - GitHub
When looking for useful content regarding osu! replay viewers, you can find a mix of analysis tools for self-improvement, web-based playback options, and video rendering services. Advanced Analysis Tools
These tools go beyond the standard game client to help you understand why you missed a note or how your accuracy (UR) fluctuates.
Rewind: A highly recommended third-party program specifically for replay analysis. It features a scrub bar, difficulty graphs on the timeline, and the ability to toggle "Hidden" mode off to see exactly how misses occurred.
Circleguard: Primarily a cheat detection tool, but it includes a powerful visualization panel. It allows frame-by-frame movement, jumps to any point in time, and provides raw replay data in a table format. Web-Based & External Viewers
If you want to watch replays without opening the full game client, several community projects offer lightweight alternatives.
o!rdr (osu! replay video generator): An online service that renders your .osr files into high-quality videos using Danser. It is useful for sharing plays on Discord or social media without requiring others to have the game installed.
osu-replay-viewer (GitHub): A project based on osu!lazer components that allows you to view replays and render them to video files using FFmpeg without launching the main game. osu replay viewer
osuweb-replay: A browser-based tool where you can drag and drop replay files to watch them directly in Chrome or Firefox. Tips for Analyzing Your Own Gameplay
Reviewers and high-level players often suggest specific techniques when using these viewers:
Frame-by-Frame Review: Use tools like Rewind or Circleguard to see if you are clicking too early, too late, or if your cursor is "snapping" incorrectly.
Playback Speed: Standard clients allow 0.5x speed, but dedicated viewers like Rewind allow even finer control (0.25x to 4.0x) to pinpoint slider breaks.
Heatmaps and UR Bars: Pay attention to the hit error bar and Unstable Rate (UR) statistics provided by these viewers to identify consistency issues across long maps. Let's Play osu! Episode 62: Replay Analysis & Rewind
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>osu! replay viewer · live visualization</title>
<style>
*
box-sizing: border-box;
user-select: none; /* smoother drag/scrub interactions */
body
background: linear-gradient(145deg, #0a0f1e 0%, #0c1222 100%);
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Roboto', monospace;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 24px;
/* main card */
.viewer-container
max-width: 1300px;
width: 100%;
background: rgba(18, 25, 45, 0.75);
backdrop-filter: blur(2px);
border-radius: 2.5rem;
box-shadow: 0 25px 45px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05);
padding: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.08);
/* header and replay info */
.header
display: flex;
justify-content: space-between;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: 1.5rem;
gap: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 0.75rem;
.title
font-size: 1.8rem;
font-weight: 700;
background: linear-gradient(135deg, #ff9a9e, #fad0c4, #fad0c4);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
letter-spacing: -0.5px;
.badge
background: #1e2a3e;
padding: 6px 14px;
border-radius: 60px;
font-size: 0.8rem;
font-weight: 500;
color: #b9e6ff;
font-family: monospace;
border: 1px solid #2d4055;
/* two column layout */
.dashboard
display: flex;
flex-wrap: wrap;
gap: 1.8rem;
.visualization-panel
flex: 2;
min-width: 280px;
background: #0b111fcc;
border-radius: 1.8rem;
backdrop-filter: blur(4px);
padding: 1rem;
border: 1px solid rgba(255, 255, 255, 0.05);
.controls-panel
flex: 1.2;
min-width: 260px;
background: #0b111faa;
border-radius: 1.8rem;
padding: 1rem 1.2rem;
border: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
gap: 1.4rem;
canvas
display: block;
width: 100%;
background: #03060e;
border-radius: 1.5rem;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
cursor: crosshair;
#replayCanvas
width: 100%;
height: auto;
background: radial-gradient(circle at 30% 20%, #141e2c, #010101);
/* slider & time */
.scrub-area
margin-top: 1rem;
input[type="range"]
width: 100%;
height: 4px;
-webkit-appearance: none;
background: #2a3a55;
border-radius: 5px;
outline: none;
input[type="range"]:focus
outline: none;
input[type="range"]::-webkit-slider-thumb
-webkit-appearance: none;
width: 16px;
height: 16px;
background: #ffb347;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 6px #ffaa33;
border: none;
.time-display
display: flex;
justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: #bbd9ff;
margin-top: 8px;
.playback-buttons
display: flex;
gap: 12px;
justify-content: center;
margin: 12px 0 8px;
.playback-buttons button
background: #1f2a3e;
border: none;
color: white;
padding: 8px 18px;
border-radius: 40px;
font-weight: bold;
cursor: pointer;
transition: 0.1s linear;
font-family: inherit;
backdrop-filter: blur(4px);
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
.playback-buttons button:hover
background: #ff884d;
transform: scale(0.97);
color: #0a0f1e;
.stats
background: #00000040;
border-radius: 1.2rem;
padding: 0.8rem;
font-size: 0.85rem;
font-family: monospace;
.stat-row
display: flex;
justify-content: space-between;
border-bottom: 1px dashed #2e405b;
padding: 5px 0;
.cursor-status
background: #111a28;
border-radius: 1rem;
padding: 0.8rem;
text-align: center;
.hit-circle
display: inline-block;
width: 12px;
height: 12px;
background: #ff4d6d;
border-radius: 50%;
margin-right: 8px;
box-shadow: 0 0 6px #ff4d6d;
.accuracy
font-size: 1.6rem;
font-weight: 800;
color: #c7f9cc;
kbd
background: #2c3e50;
border-radius: 6px;
padding: 2px 6px;
font-family: monospace;
font-size: 0.7rem;
footer
font-size: 0.7rem;
text-align: center;
margin-top: 1.2rem;
color: #5f7f9e;
.file-zone
background: #0f172ac9;
border-radius: 1.2rem;
padding: 0.7rem;
text-align: center;
border: 1px dashed #3e5a77;
cursor: pointer;
transition: 0.1s;
.file-zone:hover
background: #1a253f;
</style>
</head>
<body>
<div class="viewer-container">
<div class="header">
<span class="title">⌨️ osu! replay viewer · kinetic timeline</span>
<span class="badge">⚡ replay analyzer</span>
</div>
<div class="dashboard">
<!-- canvas visualization area -->
<div class="visualization-panel">
<canvas id="replayCanvas" width="800" height="500" style="width:100%; height:auto; aspect-ratio:800/500"></canvas>
<div class="scrub-area">
<input type="range" id="timelineSlider" min="0" max="100" step="0.1" value="0">
<div class="time-display">
<span>🎵 <span id="currentTimeLabel">0.00</span>s</span>
<span>⏱️ <span id="totalTimeLabel">0.00</span>s</span>
</div>
<div class="playback-buttons">
<button id="playPauseBtn">▶ PLAY</button>
<button id="resetBtn">⟳ RESET</button>
</div>
</div>
</div>
<!-- right panel: stats + replay data -->
<div class="controls-panel">
<div class="file-zone" id="fileUploadZone">
📂 LOAD REPLAY (.json / simulated)<br>
<small style="opacity:0.7">click or drag — demo included</small>
<input type="file" id="replayFileInput" accept=".json" style="display:none">
</div>
<div class="stats">
<div class="stat-row"><span>🎯 Clicks / hits</span><span id="totalHits">0</span></div>
<div class="stat-row"><span>✔️ Max combo (sim)</span><span id="maxCombo">0</span></div>
<div class="stat-row"><span>💥 Accuracy (est.)</span><span id="accuracyStat">0%</span></div>
<div class="stat-row"><span>🖱️ Cursor events</span><span id="cursorEventsCount">0</span></div>
</div>
<div class="cursor-status">
<div><span class="hit-circle"></span> <strong>实时光标轨迹 / 点击事件</strong></div>
<div style="font-size:0.75rem; margin-top:8px;" id="liveCoord">X: --- , Y: ---</div>
<div id="lastAction">⚡ 等待回放</div>
</div>
<div class="stats">
<div class="stat-row"><span>🎮 当前帧点击</span><span id="currentClickFlag">—</span></div>
<div class="stat-row"><span>⏲️ Replay 速率</span><span id="playbackRateDisplay">1.0x</span></div>
</div>
<div style="text-align: center; font-size:0.7rem;">
<kbd>◀</kbd> <kbd>▶</kbd> seek · <kbd>SPACE</kbd> play/pause
</div>
</div>
</div>
<footer>✨ 可视化 replay 关键帧 (光标轨迹 + 点击标记) — 支持自定义 JSON 格式: "replayData": [ "t": ms, "x": 0-800, "y": 0-500, "click": bool ], "durationMs": number 或使用内置范例</footer>
</div>
<script>
(function()
// ---------- canvas elements ----------
const canvas = document.getElementById('replayCanvas');
const ctx = canvas.getContext('2d');
// dimensions fixed to 800x500 (playfield style)
canvas.width = 800;
canvas.height = 500;
// replay data structures
let replayFrames = []; // each: timeMs, x, y, click
let totalDuration = 5000; // ms
let currentTime = 0; // ms
let animationId = null;
let isPlaying = false;
let lastTimestamp = 0;
// UI elements
const timelineSlider = document.getElementById('timelineSlider');
const currentTimeLabel = document.getElementById('currentTimeLabel');
const totalTimeLabel = document.getElementById('totalTimeLabel');
const playPauseBtn = document.getElementById('playPauseBtn');
const resetBtn = document.getElementById('resetBtn');
const totalHitsSpan = document.getElementById('totalHits');
const maxComboSpan = document.getElementById('maxCombo');
const accuracyStatSpan = document.getElementById('accuracyStat');
const cursorEventsCountSpan = document.getElementById('cursorEventsCount');
const liveCoordSpan = document.getElementById('liveCoord');
const lastActionSpan = document.getElementById('lastAction');
const currentClickFlagSpan = document.getElementById('currentClickFlag');
const playbackRateDisplay = document.getElementById('playbackRateDisplay');
// stats accumulators
let totalClicks = 0; // number of clicks in replay
let currentCombo = 0;
let maxComboReached = 0;
let hitAccuracyEstimate = 100; // dummy simulated w/ clicks vs time windows
// helper: update stats from replayFrames
function recomputeStats()
totalClicks = replayFrames.filter(f => f.click === true).length;
// For better simulation: accuracy based on clicking consistency? we simulate "perfect hit ratio" using click density.
// but for visual fun: we assume 95% baseline with a slight variance based on click frequency?
// For cleaner demo: each click is considered a "hit" and we calculate an estimated accuracy relative to beat density?
// better: mock accuracy = min(100, Math.floor(85 + (totalClicks / Math.max(1, replayFrames.length/5)) * 10));
let clickEvents = totalClicks;
let totalFrames = replayFrames.length;
let clickDensity = totalFrames ? (clickEvents / totalFrames) * 100 : 0;
let mockAcc = Math.min(99, Math.floor(78 + clickDensity * 0.4));
if (mockAcc > 98) mockAcc = 96 + (clickEvents % 3);
hitAccuracyEstimate = Math.min(100, Math.max(65, mockAcc));
accuracyStatSpan.innerText = hitAccuracyEstimate + "%";
// compute max combo: consecutive frames with click? but combo is based on hits in rhythm, we use consecutive clicks within time diff < 200ms as combo
let combo = 0;
let bestCombo = 0;
let lastClickTime = -1000;
for (let frame of replayFrames)
if (frame.click)
if (lastClickTime === -1000
maxComboReached = bestCombo;
maxComboSpan.innerText = maxComboReached;
totalHitsSpan.innerText = totalClicks;
cursorEventsCountSpan.innerText = replayFrames.length;
// draw the entire scene based on current playback time
function drawVisualization()
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// background gradient
const grad = ctx.createLinearGradient(0, 0, 0, canvas.height);
grad.addColorStop(0, '#091222');
grad.addColorStop(1, '#03070f');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw dotted grid (osu! style)
ctx.strokeStyle = '#2a3b55';
ctx.lineWidth = 0.5;
for (let i = 0; i < canvas.width; i += 40)
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, canvas.height);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(canvas.width, i);
ctx.stroke();
// draw all cursor trail (semi-transparent based on time)
for (let i = 0; i < replayFrames.length; i++)
const frame = replayFrames[i];
if (frame.timeMs > currentTime) continue;
const alpha = 0.25 + (frame.timeMs / totalDuration) * 0.3;
ctx.beginPath();
ctx.arc(frame.x, frame.y, 5, 0, Math.PI*2);
ctx.fillStyle = `rgba(100, 180, 255, $Math.min(0.5, alpha*0.7))`;
ctx.fill();
if (frame.click && frame.timeMs <= currentTime)
ctx.beginPath();
ctx.arc(frame.x, frame.y, 12, 0, Math.PI*2);
ctx.strokeStyle = '#ff6070';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.beginPath();
ctx.arc(frame.x, frame.y, 5, 0, Math.PI*2);
ctx.fillStyle = '#ff3366cc';
ctx.fill();
// find current interpolated cursor position
let curX = canvas.width/2, curY = canvas.height/2;
let isClickNow = false;
if (replayFrames.length > 0)
let prevFrame = null;
for (let i = 0; i < replayFrames.length; i++)
if (replayFrames[i].timeMs <= currentTime)
prevFrame = replayFrames[i];
else break;
let nextFrame = replayFrames.find(f => f.timeMs > currentTime);
if (prevFrame)
if (nextFrame)
const t = (currentTime - prevFrame.timeMs) / (nextFrame.timeMs - prevFrame.timeMs);
const clampT = Math.min(1, Math.max(0, t));
curX = prevFrame.x + (nextFrame.x - prevFrame.x) * clampT;
curY = prevFrame.y + (nextFrame.y - prevFrame.y) * clampT;
isClickNow = prevFrame.click && (currentTime - prevFrame.timeMs < 50) ? prevFrame.click : false;
if ((nextFrame.click && (nextFrame.timeMs - currentTime) < 30)) isClickNow = true;
else
curX = prevFrame.x;
curY = prevFrame.y;
isClickNow = prevFrame.click && (currentTime - prevFrame.timeMs) < 80;
// also check if any frame exact click within tiny window
const nearClick = replayFrames.find(f => Math.abs(f.timeMs - currentTime) < 45 && f.click);
if (nearClick) isClickNow = true;
// Draw cursor (follow poi)
ctx.shadowBlur = 10;
ctx.shadowColor = '#0af';
ctx.beginPath();
ctx.arc(curX, curY, 14, 0, Math.PI*2);
ctx.fillStyle = isClickNow ? '#ff4d6dc9' : '#ffffffcc';
ctx.fill();
ctx.beginPath();
ctx.arc(curX, curY, 6, 0, Math.PI*2);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.beginPath();
ctx.arc(curX, curY, 3, 0, Math.PI*2);
ctx.fillStyle = '#ffaa55';
ctx.fill();
ctx.shadowBlur = 0;
// show click halo if actively clicking
if (isClickNow)
ctx.beginPath();
ctx.arc(curX, curY, 22, 0, Math.PI*2);
ctx.strokeStyle = '#ff8080';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(curX, curY, 28, 0, Math.PI*2);
ctx.strokeStyle = '#ffa0a0';
ctx.lineWidth = 1;
ctx.stroke();
lastActionSpan.innerHTML = '🔴 CLICK!';
currentClickFlagSpan.innerHTML = '● HIT';
else
lastActionSpan.innerHTML = '⚡ cursor tracking';
currentClickFlagSpan.innerHTML = '○ idle';
liveCoordSpan.innerText = `X: $Math.floor(curX) , Y: $Math.floor(curY)`;
// Draw time progress arc on bottom right
const progress = currentTime / totalDuration;
ctx.font = "bold 14px 'JetBrains Mono'";
ctx.fillStyle = '#ccdeff';
ctx.shadowBlur = 0;
ctx.fillText(`⏵ $(currentTime/1000).toFixed(2)s`, canvas.width-90, 35);
// update slider & time labels
function syncUITime()
timelineSlider.value = (currentTime / totalDuration) * 100;
currentTimeLabel.innerText = (currentTime / 1000).toFixed(2);
totalTimeLabel.innerText = (totalDuration / 1000).toFixed(2);
drawVisualization();
// set current time and update UI, clamp
function setCurrentTime(ms)
currentTime = Math.min(totalDuration, Math.max(0, ms));
syncUITime();
// animation loop (requestAnimationFrame)
let lastFrameTime = 0;
function startAnimation()
if (animationId) cancelAnimationFrame(animationId);
function animate(now)
if (!isPlaying) return;
if (lastFrameTime === 0) lastFrameTime = now;
let delta = Math.min(100, now - lastFrameTime);
if (delta > 0)
let step = delta * 1.0; // 1x speed, can modify
let newTime = currentTime + step;
if (newTime >= totalDuration)
newTime = totalDuration;
setCurrentTime(newTime);
isPlaying = false;
playPauseBtn.innerHTML = '▶ PLAY';
cancelAnimationFrame(animationId);
animationId = null;
lastFrameTime = 0;
return;
setCurrentTime(newTime);
lastFrameTime = now;
animationId = requestAnimationFrame(animate);
lastFrameTime = 0;
animationId = requestAnimationFrame(animate);
function playReplay()
if (isPlaying) return;
if (currentTime >= totalDuration)
setCurrentTime(0);
isPlaying = true;
playPauseBtn.innerHTML = '⏸ PAUSE';
startAnimation();
function pauseReplay()
if (!isPlaying) return;
isPlaying = false;
if (animationId)
cancelAnimationFrame(animationId);
animationId = null;
playPauseBtn.innerHTML = '▶ PLAY';
function togglePlayPause()
if (isPlaying) pauseReplay();
else playReplay();
function resetReplay()
pauseReplay();
setCurrentTime(0);
function loadReplayData(framesArray, durationMs)
pauseReplay();
if (!framesArray
// generate built-in demo (smooth circular cursor + clicks)
function generateDemoReplay()
const frames = [];
const duration = 7800;
const steps = 220;
for (let i = 0; i <= steps; i++)
let t = (i / steps) * duration;
let angle = (t / duration) * Math.PI * 4;
let radius = 180;
let centerX = canvas.width/2, centerY = canvas.height/2;
let x = centerX + Math.sin(angle) * radius * (1 + Math.sin(t/700));
let y = centerY + Math.cos(angle * 1.3) * radius * 0.8;
x = Math.min(canvas.width-25, Math.max(25, x));
y = Math.min(canvas.height-30, Math.max(30, y));
let click = false;
if (Math.abs(t - 1200) < 60) click = true;
if (Math.abs(t - 2500) < 50) click = true;
if (Math.abs(t - 3800) < 60) click = true;
if (Math.abs(t - 4900) < 55) click = true;
if (Math.abs(t - 6100) < 70) click = true;
if (Math.abs(t - 7100) < 80) click = true;
frames.push( timeMs: t, x: Math.floor(x), y: Math.floor(y), click );
// extra smooth clicks
replayFrames = frames;
totalDuration = duration;
recomputeStats();
setCurrentTime(0);
syncUITime();
// handle uploaded JSON
function processUploadedJSON(jsonText)
try
const obj = JSON.parse(jsonText);
let frames = null;
let duration = null;
if (obj.replayData && Array.isArray(obj.replayData)) else if (Array.isArray(obj))
frames = obj;
duration = obj.length ? obj[obj.length-1].timeMs + 200 : 5000;
else
throw new Error("Format error, need replayData array with timeMs, x, y, click");
const validFrames = frames.filter(f => typeof f.timeMs === 'number' && typeof f.x === 'number' && typeof f.y === 'number');
if (validFrames.length === 0) throw new Error("no valid frames");
loadReplayData(validFrames, duration);
lastActionSpan.innerHTML = '📁 自定义 replay 已加载';
catch(e)
alert("Invalid JSON: " + e.message + " — using demo format");
generateDemoReplay();
// event binding
timelineSlider.addEventListener('input', (e) =>
if (isPlaying) pauseReplay();
const percent = parseFloat(e.target.value) / 100;
const newTime = percent * totalDuration;
setCurrentTime(newTime);
);
playPauseBtn.addEventListener('click', togglePlayPause);
resetBtn.addEventListener('click', resetReplay);
document.addEventListener('keydown', (e) =>
if (e.code === 'Space')
e.preventDefault();
togglePlayPause();
if (e.code === 'ArrowLeft')
e.preventDefault();
if (isPlaying) pauseReplay();
setCurrentTime(currentTime - 150);
if (e.code === 'ArrowRight')
e.preventDefault();
if (isPlaying) pauseReplay();
setCurrentTime(currentTime + 150);
);
const uploadZone = document.getElementById('fileUploadZone');
const fileInput = document.getElementById('replayFileInput');
uploadZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) =>
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) =>
processUploadedJSON(ev.target.result);
fileInput.value = '';
;
reader.readAsText(file);
);
// drag drop
uploadZone.addEventListener('dragover', (e) => e.preventDefault(); uploadZone.style.background = '#1f2e4a'; );
uploadZone.addEventListener('dragleave', () => uploadZone.style.background = ''; );
uploadZone.addEventListener('drop', (e) =>
e.preventDefault();
uploadZone.style.background = '';
const file = e.dataTransfer.files[0];
if(file && file.type === 'application/json')
const reader = new FileReader();
reader.onload = (ev) => processUploadedJSON(ev.target.result);
reader.readAsText(file);
else alert('drop JSON file plz');
);
// initial demo load
generateDemoReplay();
setCurrentTime(0);
playbackRateDisplay.innerText = "1.0x";
)();
</script>
</body>
</html>
The osu! replay viewer landscape has evolved from simple in-game playback to sophisticated third-party tools that offer pixel-perfect analysis. While the standard client allows you to save and watch scores, external programs like Rewind and Circleguard have become essential for players serious about improvement. Top Replay Viewers & Analyzers
Rewind (Overall Best): Acts like a "YouTube player" for your .osr files. It features a scrub bar, instant skin swapping, and the ability to analyze aim pixel by pixel.
Circleguard: Highly technical tool focused on cheat detection and raw data analysis. It provides frame-by-frame movement and unstable rate (UR) breakdowns.
osu!lazer (Built-in): The modern official client includes a vastly improved replay system with playback speed controls and a scrub bar, though it still lacks deep "pixel-level" analysis.
Web-Based Viewers: Sites like replayviewer.com allow you to watch replays in a browser or on mobile without installing the game. Key Features for Improvement Replay 2.0 · ppy osu · Discussion #21729 - GitHub Developing a feature for an osu
2. Beatmap Loader (optional but recommended)
.osufile format to get hit objects, timing points, dimensions- Align replay actions with beatmap objects for accuracy analysis
The Ghost in the Machine: Why osu!’s Replay Viewer is the Game’s Quietest Revolution
By Alex "ClickTheory" Chen
You’ve just set a new personal best on a 7-star Freedom Dive map. Your hands are shaking. The cursor wavers on the “Retry” button—but you don’t click it. Instead, you hover over the small, unassuming button: Watch Replay.
In most rhythm games, replays are a pat on the back. A victory lap. In osu!, they are a confession, a textbook, and occasionally, a courtroom.
The osu! replay viewer is not a feature. It is a culture.
Key Components of a Viewer
File Structure of .osr (Binary Format)
| Byte Range | Type | Description | |------------|------|-------------| | 0-3 | int | Game mode (0=std, 1=taiko, 2=ctb, 3=mania) | | 4-7 | int | Game version (e.g., 20250316) | | 8-11 | int | Beatmap MD5 hash (as string offset) | | 12-15 | int | Player name (string offset) | | 16-19 | int | Replay MD5 hash (as string offset) | | 20-21 | short | Number of 300s / Geki / etc. (mode-dependent) | | ... (varies) | ... | Counts for 100s, 50s, misses, combo, perfect flag | | 22 | byte | Mods bitwise (enum, 32-bit later in newer osu! versions) | | 23-26 | int | Life bar graph (string offset) | | 27-30 | int | Timestamp (Windows ticks) | | 31-34 | int | Replay length in bytes (for compressed data) | | 35+ | byte[] | Compressed replay data (LZ4 or older zlib) | | End | long | Replay ID (online submission) |
Option 4: Developer / "Side Project" Launch
Best for: If you built this viewer yourself and are releasing it.
Title: I built a lightweight osu! replay viewer for the community 🏸
Body: Hi r/osugame! I'm a developer and an osu! addict. I got frustrated with how heavy it was to just quickly check a replay file, so I spent the last few weeks building my own osu! Replay Viewer.
Features:
- Supports standard mode (considering adding Taiko/CtB soon!)
- Frame-by-frame stepping
- Support for custom skins (drag your skin folder in)
- Web-based / Desktop app (choose one)
It’s open source and free to use. I’d love some feedback on the UI and performance. Let me know if it breaks on any weird map mods! The osu
[Download Link / Demo]
💡 Tips for engagement:
- Include Media: If you are posting on visual platforms, include a GIF or short video of the viewer scrubbing through a replay.
- Mention Mods: If the viewer supports hidden, double time, or relax (for checking illegitimate scores), mention that explicitly.
- Clarify "Web" vs "Desktop": Users like to know if they have to download something or if they can just use it in Chrome.
The Skeleton Key
At first glance, the replay viewer is minimalist: a playfield, a ghost cursor, a timeline scrubber. But peel back the skin, and you’ll find a forensic tool. Every click, every slider break, every micro-hesitation is preserved as a sequence of raw input data. Not video. Not approximated. Actual cursor coordinates, at 240Hz or higher.
This turns every replay into a perfect biomechanical fossil.
Want to know why you missed that one note at 00:47:23? Scrub to it. Slow it down to 0.25x. Watch your cursor. Did you overshoot? Did you lift your pen too early? Did your finger stutter on the keyboard? The replay viewer doesn’t judge. It just shows you.
High-level players don’t grind maps for hours blindly. They grind replays. A top 10 global player once told me: “I spend as much time watching my own replays as I do playing. You see patterns in your failures that your brain hides in real-time.”
1. osu!replay Editor (Circleguard)
Originally known as osu-replay-editor, this tool (now integrated into Circleguard) is the gold standard for forensic replay analysis.
Key Features:
- Live Cursor Path and Hit Error Bars: Displays a line showing if you hit early (left) or late (right) on each circle.
- Replay Timeline Scrubbing: Drag a slider to jump to any millisecond of the play.
- Slider End Accuracy: Shows exactly when you released the slider.
- Comparison Mode: Load two replays (e.g., your play vs. a top player’s play) and watch them side-by-side.
Best for: Tracking consistency across long maps.