SCRIPT LIBRARY · POWERSHELL
Sort a Game Collection Into Tiers by Review Score
Moves game files into Must-Play, Great, Good, Average and Niche folders using a CSV of titles and scores, with series fallbacks, so a huge backlog shows you the best stuff first.
- What it does
- Reads a CSV of titles and 0-100 scores, matches each game file in a folder to its row, and moves the file into a tier folder (1 Must-Play, 2 Great, 3 Good, 4 Average, 5 Niche). Short rows act as series fallbacks. Unmatched games stay put or go to an Unrated folder.
- Requires
- Windows PowerShell 5.1 or PowerShell 7+
- No modules
- A CSV with Title and Score columns that you put together
- Permissions
- Read and write access to the game folder. No admin rights.
- Runs on
- Windows 10/11, macOS or Linux with PowerShell 7
- Tested
- Parse-checked and run in PowerShell 7.4 against a folder of empty dummy files, with -WhatIf, for real, with an Unrated folder, genre-tagged names, apostrophes in titles and a series fallback row
Part 9 of the thread Keeping a media library tidy
A full set for any console is hundreds of games, and most of them you'll never play. When I sit down with a system I haven't touched in a while, I want the best twenty in front of me, not an alphabetical wall.
So this script sorts a folder into tiers. You give it a CSV of titles and scores, from Metacritic, your favorite review site, or your own opinions. Anything 90 or above goes in "1 Must-Play", 80s in "2 Great", and so on down. A short row like "Mario Party" works as a fallback for a whole series, and a full title always beats it.
It moves files, it never deletes or overwrites, and -WhatIf shows you the whole plan first.
<#
.SYNOPSIS
Sorts games into tier folders (Must-Play, Great, Good, Average, Niche) from a CSV of titles and review scores.
.DESCRIPTION
Reads a CSV with Title and Score columns (a 0-100 score from Metacritic, a review site or your own opinion).
For each game file in the folder it finds the matching row, working out the tier from the score:
90 and up: 1 Must-Play, 80-89: 2 Great, 70-79: 3 Good, 60-69: 4 Average, below 60: 5 Niche
and moves the file into that tier's subfolder, creating the folders it needs.
A row matches when its Title appears anywhere in the file's title, ignoring case and punctuation, and the longest
match wins. That lets a short row like "Mario Party" act as a fallback for a whole series, while a full title
still beats it.
Files with no match stay where they are (Status Unrated), or go to -UnratedFolder if you name one.
Existing files are never overwritten. Every file gets an object back. Supports -WhatIf.
.PARAMETER Path
The folder of games. Only files directly in it are sorted, so running it twice doesn't re-sort the tier folders.
.PARAMETER ScoreCsv
CSV with Title and Score columns.
.PARAMETER Extension
File extensions to sort. Default: every file.
.PARAMETER Threshold
The four cut-off scores, highest first. Default: 90, 80, 70, 60.
.PARAMETER TierName
The five folder names, best first.
.PARAMETER UnratedFolder
Folder name for games with no score. Default: leave them where they are.
.EXAMPLE
.\Move-RomToQualityTier.ps1 -Path D:\Games\gamecube -ScoreCsv .\gamecube-scores.csv -Extension .rvz,.iso -WhatIf
.EXAMPLE
.\Move-RomToQualityTier.ps1 -Path D:\Games\ps2 -ScoreCsv .\ps2-scores.csv -UnratedFolder '6 Unrated' | Export-Csv sorted.csv -NoTypeInformation
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
[string]$Path,
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
[string]$ScoreCsv,
[string[]]$Extension,
[ValidateCount(4, 4)]
[ValidateRange(0, 100)]
[int[]]$Threshold = @(90, 80, 70, 60),
[ValidateCount(5, 5)]
[ValidateNotNullOrEmpty()]
[string[]]$TierName = @('1 Must-Play', '2 Great', '3 Good', '4 Average', '5 Niche'),
[string]$UnratedFolder
)
function Get-MatchKey([string]$Text) { ' ' + ($Text.ToLowerInvariant() -replace '[\u0027\u2019]', '' -replace '&', 'and' -replace '[^a-z0-9]+', ' ').Trim() + ' ' }
for ($i = 1; $i -lt $Threshold.Count; $i++) {
if ($Threshold[$i] -ge $Threshold[$i - 1]) { throw "Thresholds must go from highest to lowest, like 90,80,70,60." }
}
$scores = foreach ($row in Import-Csv -LiteralPath $ScoreCsv) {
$score = 0
if ($row.Title -and [int]::TryParse(($row.Score -as [string]).Trim(), [ref]$score) -and $score -ge 0 -and $score -le 100) {
[pscustomobject]@{ Title = $row.Title; Key = (Get-MatchKey $row.Title); Score = $score }
}
elseif ($row.Title) { Write-Warning "Skipping CSV row '$($row.Title)': score '$($row.Score)' isn't a number from 0 to 100." }
}
Write-Verbose "Loaded $(@($scores).Count) scored titles from $ScoreCsv"
$extFilter = @($Extension | Where-Object { $_ } | ForEach-Object { if ($_ -like '.*') { $_.ToLower() } else { ".$_".ToLower() } })
$files = Get-ChildItem -LiteralPath $Path -File | Where-Object { -not $extFilter -or $extFilter -contains $_.Extension.ToLower() }
foreach ($file in $files) {
# Strip a leading genre code like (PLAT) and everything from the first bracket, to get the title.
$title = (($file.BaseName -replace '^\([A-Z]{3,4}\)\s*', '') -replace '\s*[\(\[].*$', '').Trim()
$key = Get-MatchKey $title
$match = $scores | Where-Object { $key.Contains($_.Key) } | Sort-Object { $_.Key.Length } -Descending | Select-Object -First 1
$result = [ordered]@{ Name = $file.Name; MatchedTitle = $null; Score = $null; Tier = $null; Status = 'Unrated' }
$folder = $null
if ($match) {
$result.MatchedTitle = $match.Title
$result.Score = $match.Score
$index = @($Threshold | Where-Object { $match.Score -lt $_ }).Count
$folder = $TierName[$index]
$result.Tier = $folder
}
elseif ($UnratedFolder) { $folder = $UnratedFolder; $result.Tier = $UnratedFolder }
if (-not $folder) { [pscustomobject]$result; continue }
$destFolder = Join-Path $Path $folder
$dest = Join-Path $destFolder $file.Name
if (Test-Path -LiteralPath $dest) {
$result.Status = 'SkippedExists'
Write-Warning "$($file.Name) is already in '$folder'. Left alone."
[pscustomobject]$result; continue
}
$result.Status = 'WhatIf'
if ($PSCmdlet.ShouldProcess($file.FullName, "Move to $folder")) {
try {
if (-not (Test-Path -LiteralPath $destFolder)) { New-Item -ItemType Directory -Path $destFolder -ErrorAction Stop | Out-Null }
Move-Item -LiteralPath $file.FullName -Destination $dest -ErrorAction Stop
$result.Status = 'Moved'
}
catch {
$result.Status = "Failed: $($_.Exception.Message)"
Write-Warning "$($file.Name): $($_.Exception.Message)"
}
}
[pscustomobject]$result
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Path | string | — | Required. The folder of games. Only files directly in it are sorted. |
-ScoreCsv | string | — | Required. CSV with Title and Score (0 to 100) columns. |
-Extension | string[] | every file | Only sort these extensions. |
-Threshold | int[] | 90, 80, 70, 60 | The four cut-off scores, highest first. |
-TierName | string[] | 1 Must-Play ... 5 Niche | The five folder names, best first. The numbers keep them in order in a file browser. |
-UnratedFolder | string | — | A folder for games with no score. Without it, they stay where they are. |
Run it
Preview how a GameCube folder would sort.
.\Move-RomToQualityTier.ps1 -Path D:\Games\gamecube -ScoreCsv .\gamecube-scores.csv -Extension .rvz,.iso -WhatIfSee which games the CSV is missing.
.\Move-RomToQualityTier.ps1 -Path D:\Games\gamecube -ScoreCsv .\gamecube-scores.csv -WhatIf | Where-Object Status -eq UnratedStricter tiers, with unscored games in their own folder, and keep the list.
.\Move-RomToQualityTier.ps1 -Path D:\Games\ps2 -ScoreCsv .\ps2-scores.csv -Threshold 92,85,75,65 -UnratedFolder '6 Unrated' | Export-Csv sorted.csv -NoTypeInformationWhat you'll see
Name MatchedTitle Score Tier Status
---- ------------ ----- ---- ------
(PLAT) Mario Party 4 (USA).rvz Mario Party 75 3 Good Moved
F-Zero GX (USA).rvz F-Zero GX 89 2 Great Moved
Luigi's Mansion (USA).rvz Luigis Mansion 78 3 Good Moved
Metroid Prime (USA).rvz Metroid Prime 97 1 Must-Play Moved
Mystery Game (USA).rvz Unrated
Sonic Heroes (USA).rvz Sonic 65 4 Average Moved
How it works
- Check the settings. The four thresholds must go from highest to lowest, and there must be exactly five tier names.
- Load the scores. Rows with a missing or out-of-range score get a warning and are skipped. Titles are squashed to lowercase letters and numbers so punctuation and apostrophes don't matter.
- Get each file's title. A leading genre code like
(PLAT)is ignored, and so is everything from the first bracket on. - Find the best row. Every row whose title appears inside the file's title is a candidate, and the longest wins, which is how a series fallback and a specific title live side by side.
- Pick the tier. The score is compared with the thresholds: 90 and up is tier 1, 80 to 89 tier 2, and so on.
- Move it. The tier folder is created if needed, and the file moves in, unless a file with the same name is already there.
Take it further
- Tag genres first. With Add-RomGenreTag, the tier folders end up sorted by genre inside, which makes the Must-Play folder a very good menu.
- Use your own scores. Add a column for your rating and export that as Score instead. Your tiers, not the critics'.
- Pair it with a cleanup. Set the Niche folder aside with Move-RomByKeyword or just leave it. Nothing here deletes anything.
Things that'll trip you up
- Scores are only as good as the CSV. The script doesn't look anything up. A score list from a review aggregator is a fine start, but cult favorites tend to land in Average or Niche. Edit the scores to match your own taste; it's your collection.
- Series rows are blunt. A "Sonic" row puts every Sonic game in the same tier unless a longer row overrides it. Add full titles for the ones that deserve better, or worse.
- Tier folders move the files. Frontends and emulators pointed at the system folder may not look in subfolders. Check yours scans recursively, or point it at the tier folders.
- Only the top level is sorted. Files already inside tier folders aren't touched, so running it again after adding games only sorts the new ones. To re-sort everything, move the files back up first.
- Set Aside Unwanted ROMs by Keyword, Without Deleting AnythingScript Library
- Tag Game Backups With a Genre Code From a CSVScript Library