Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,35 @@
# GameCopier
This is a roblox game copier please do not use the api key

Dark-themed web UI + local CLI for duplicating **your own exported Roblox place files**.

## Important
This project is for files you own and are authorized to copy. Do not use it to duplicate other creators' games without permission.

## Web UI
Open `index.html` to use the themed “Game Copier” page.

- Paste a local file path (`.rbxl` or `.rbxlx`)
- Click **COPY GAME!**
- The UI validates input and shows the prepared local-copy status

## Python CLI
Generate key:

```bash
python3 roblox_game_copier.py init-key
```

Copy local file:

```bash
python3 roblox_game_copier.py copy \
--api-key "<PASTE_KEY>" \
--source ./MyGame.rbxl \
--out-dir ./backups
```

## Tests

```bash
python3 -m unittest -v
```
234 changes: 234 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Game Copier</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;800&display=swap" rel="stylesheet" />
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: 'Inter', system-ui, sans-serif;
color: #f4f4f5;
background:
radial-gradient(70% 80% at 50% 55%, rgba(22, 25, 35, 0.85), transparent 70%),
linear-gradient(180deg, #0b0d11, #090b0f);
}
.page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.top-nav {
position: fixed; top: 14px; left: 50%; transform: translateX(-50%);
display: flex; gap: 10px; padding: 10px 12px; border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(33, 33, 33, 0.64);
box-shadow: 0 10px 30px rgba(0,0,0,0.45), inset 0 0 18px rgba(255,255,255,0.05);
backdrop-filter: blur(8px);
}
.nav-link {
color: #a1a1aa; text-decoration: none; font-weight: 600;
padding: 10px 16px; border-radius: 999px; border: 0; background: transparent; cursor: pointer;
}
.nav-link.active { color: #f4f4f5; background: radial-gradient(circle at 50% 0%, rgba(255,255,255,0.28), rgba(255,255,255,0.06)); }
.card {
width: min(620px, 95vw); margin-top: 64px; padding: 40px; border-radius: 24px;
border: 1px solid rgba(123,128,144,0.35);
background: linear-gradient(180deg, rgba(30,31,35,0.88), rgba(22,23,27,0.86));
box-shadow: 0 25px 75px rgba(0,0,0,0.58), inset 0 0 70px rgba(255,255,255,0.02);
}
h1 { margin: 0; text-align: center; font-size: clamp(2rem, 4vw, 3rem); text-shadow: 0 0 16px rgba(255,255,255,.2); }
.subtext { margin: 16px 0 22px; text-align: center; color: #a1a1aa; line-height: 1.45; font-size: 1.02rem; }
.panel { display: none; }
.panel.active { display: block; }
input {
width: 100%; border-radius: 16px; border: 2px solid rgba(126,129,140,.32); background: rgba(8,9,12,.95);
color: #e4e4e7; padding: 14px 16px; font-size: 1rem; outline: none; margin-bottom: 12px;
}
input:focus { border-color: rgba(210,210,220,.8); box-shadow: 0 0 0 4px rgba(211,211,220,.12); }
.btn {
width: 100%; border: 0; border-radius: 16px; background: linear-gradient(180deg, #f4f4f5, #d4d4d8);
color: #111; font-size: 1.2rem; font-weight: 800; letter-spacing: .04em;
padding: 14px; cursor: pointer; transition: transform 130ms ease, box-shadow 130ms ease;
box-shadow: 0 0 24px rgba(255,255,255,.18);
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(1px) scale(.99); }
.btn.clicked { animation: pulse 420ms ease; }
.btn.secondary { margin-top: 8px; background: linear-gradient(180deg, #373a45, #2d3038); color: #ececf0; font-size: .95rem; }
@keyframes pulse { 0%{box-shadow:0 0 24px rgba(255,255,255,.2)} 50%{box-shadow:0 0 36px rgba(255,255,255,.45)} 100%{box-shadow:0 0 24px rgba(255,255,255,.2)} }
.status {
margin-top: 12px; border-radius: 12px; border: 1px solid rgba(112,116,130,.35);
background: rgba(10,11,16,.92); color: #a1a1aa; padding: 14px; min-height: 56px; font-size: .93rem;
}
ul { margin: 0; padding-left: 18px; color: #b7b7be; line-height: 1.5; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
</style>
</head>
<body>
<main class="page">
<nav class="top-nav" aria-label="Primary">
<button class="nav-link" data-tab="home">Home</button>
<button class="nav-link" data-tab="features">Features</button>
<button class="nav-link" data-tab="info">Info</button>
<button class="nav-link active" data-tab="tools">Tools</button>
</nav>

<section class="card" aria-labelledby="title">
<h1 id="title">Game Copier</h1>
<p class="subtext" id="subtitle">Paste your own exported game file path below, then click “Copy Game!”.</p>

<div id="panel-tools" class="panel active">
<label for="gameFile" class="sr-only">Enter game file</label>
<input id="gameFile" type="text" placeholder="Enter game file (e.g. C:\\Games\\MyGame.rbxl)" />
<button id="copyBtn" class="btn">COPY GAME!</button>
<button id="psDownloadBtn" class="btn secondary">COPY AS POWERSHELL (.ps1)</button>
<pre id="status" class="status">Ready.</pre>
</div>

<div id="panel-features" class="panel">
<p class="subtext" style="margin-top:0">Join helper (friends with joins enabled only):</p>
<input id="joinUrl" type="text" placeholder="Paste Roblox join link from friend/profile" />
<button id="joinBtn" class="btn">JOIN FRIEND</button>
<button id="friendsPageBtn" class="btn secondary">Open Roblox Friends Page</button>
<pre id="joinStatus" class="status">Paste a valid Roblox join URL. This does not bypass privacy settings.</pre>
</div>

<div id="panel-home" class="panel">
<ul>
<li>Use the Tools tab to validate and prepare local `.rbxl` / `.rbxlx` copy actions.</li>
<li>Use “Copy as PowerShell” to generate a `.ps1` file with your explicit download permission.</li>
<li>Use Features tab to quickly open join links when your friend has joins enabled.</li>
</ul>
</div>

<div id="panel-info" class="panel">
<ul>
<li>This UI is local and does not clone protected games.</li>
<li>Joining only works when the target user allows joins.</li>
<li>No privacy/security bypass functionality is included.</li>
</ul>
</div>
</section>
</main>

<script>
const navButtons = document.querySelectorAll('.nav-link');
const panels = {
tools: document.getElementById('panel-tools'),
features: document.getElementById('panel-features'),
home: document.getElementById('panel-home'),
info: document.getElementById('panel-info'),
};

function activateTab(name) {
navButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
Object.entries(panels).forEach(([key, panel]) => panel.classList.toggle('active', key === name));
}

navButtons.forEach(btn => {
btn.addEventListener('click', () => activateTab(btn.dataset.tab));
});

const copyBtn = document.getElementById('copyBtn');
const psDownloadBtn = document.getElementById('psDownloadBtn');
const gameFileInput = document.getElementById('gameFile');
const statusBox = document.getElementById('status');

function stamp() { return new Date().toLocaleTimeString(); }
function clickAnim(button) {
button.classList.remove('clicked');
void button.offsetWidth;
button.classList.add('clicked');
}

function validateGameFilePath(file) {
if (!file) {
statusBox.textContent = `[${stamp()}] Enter a local .rbxl or .rbxlx file path first.`;
return false;
}
const lower = file.toLowerCase();
const isValid = lower.endsWith('.rbxl') || lower.endsWith('.rbxlx');
if (!isValid) {
statusBox.textContent = `[${stamp()}] Unsupported file. Use .rbxl or .rbxlx.`;
return false;
}
return true;
}

copyBtn.addEventListener('click', () => {
clickAnim(copyBtn);
const file = gameFileInput.value.trim();
if (!validateGameFilePath(file)) return;
statusBox.textContent = `[${stamp()}] Prepared local copy command for ${file} -> ./backups`;
});

psDownloadBtn.addEventListener('click', () => {
clickAnim(psDownloadBtn);
const file = gameFileInput.value.trim();
if (!validateGameFilePath(file)) return;

const allowed = window.confirm('Allow this website to download a PowerShell script (.ps1) to your computer?');
if (!allowed) {
statusBox.textContent = `[${stamp()}] Download cancelled. Permission was not granted.`;
return;
}

const escapedPath = file.replace(/'/g, "''");
const script = [
'$ErrorActionPreference = "Stop"',
`$source = '${escapedPath}'`,
'$destinationDir = Join-Path $PSScriptRoot "backups"',
'if (-not (Test-Path $source)) { throw "Source file not found: $source" }',
'if (-not (Test-Path $destinationDir)) { New-Item -ItemType Directory -Path $destinationDir | Out-Null }',
'$timestamp = Get-Date -Format "yyyyMMddTHHmmssZ"',
'$name = [System.IO.Path]::GetFileNameWithoutExtension($source)',
'$ext = [System.IO.Path]::GetExtension($source)',
'$destination = Join-Path $destinationDir ("{0}-backup-{1}{2}" -f $name, $timestamp, $ext)',
'Copy-Item -Path $source -Destination $destination -Force',
'Write-Host "Backup created: $destination"'
].join("\r\n");

const blob = new Blob([script], { type: 'text/plain;charset=utf-8' });
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = 'game-copy-backup.ps1';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(blobUrl);

statusBox.textContent = `[${stamp()}] Downloaded PowerShell script: game-copy-backup.ps1`;
});

const joinBtn = document.getElementById('joinBtn');
const friendsPageBtn = document.getElementById('friendsPageBtn');
const joinUrl = document.getElementById('joinUrl');
const joinStatus = document.getElementById('joinStatus');

joinBtn.addEventListener('click', () => {
clickAnim(joinBtn);
const value = joinUrl.value.trim();
if (!value) {
joinStatus.textContent = `[${stamp()}] Paste a Roblox join URL first.`;
return;
}
const isRobloxUrl = /^https?:\/\/(www\.)?roblox\.com\//i.test(value);
if (!isRobloxUrl) {
joinStatus.textContent = `[${stamp()}] Invalid URL. Use a https://www.roblox.com/... link.`;
return;
}
window.open(value, '_blank', 'noopener,noreferrer');
joinStatus.textContent = `[${stamp()}] Opened link. You can join only if that user has joins enabled.`;
});

friendsPageBtn.addEventListener('click', () => {
clickAnim(friendsPageBtn);
window.open('https://www.roblox.com/users/friends', '_blank', 'noopener,noreferrer');
joinStatus.textContent = `[${stamp()}] Opened Roblox Friends page.`;
});
</script>
</body>
</html>
97 changes: 97 additions & 0 deletions roblox_game_copier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Local Roblox game backup copier protected by a custom API key.

This tool is intentionally for copying Roblox place files you already own and have
exported locally (.rbxl or .rbxlx).
"""

from __future__ import annotations

import argparse
import hashlib
import secrets
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path

DEFAULT_KEY_FILE = Path('.gamecopier_api_key')


def generate_api_key(prefix: str = 'GC') -> str:
return f"{prefix}-{secrets.token_urlsafe(32)}"


def hash_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode('utf-8')).hexdigest()


def save_key(raw_key: str, key_file: Path = DEFAULT_KEY_FILE) -> None:
key_file.write_text(hash_key(raw_key), encoding='utf-8')


def load_key_hash(key_file: Path = DEFAULT_KEY_FILE) -> str:
if not key_file.exists():
raise FileNotFoundError(
f"API key file '{key_file}' was not found. Run init-key first."
)
return key_file.read_text(encoding='utf-8').strip()


def validate_api_key(raw_key: str, key_file: Path = DEFAULT_KEY_FILE) -> bool:
return hash_key(raw_key) == load_key_hash(key_file)


def copy_game_file(source: Path, destination_dir: Path) -> Path:
if source.suffix.lower() not in {'.rbxl', '.rbxlx'}:
raise ValueError('Source must be a .rbxl or .rbxlx file.')
if not source.exists():
raise FileNotFoundError(f"Source file '{source}' does not exist.")

destination_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
destination_file = destination_dir / f"{source.stem}-backup-{timestamp}{source.suffix}"
shutil.copy2(source, destination_file)
return destination_file


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description='Copy your own exported Roblox game file with API-key protection.'
)
subparsers = parser.add_subparsers(dest='command', required=True)

subparsers.add_parser('init-key', help='Generate and save a new API key.')

copy_parser = subparsers.add_parser('copy', help='Copy a local .rbxl/.rbxlx file.')
copy_parser.add_argument('--api-key', required=True, help='Your custom GameCopier API key.')
copy_parser.add_argument('--source', required=True, help='Path to your source .rbxl/.rbxlx file.')
copy_parser.add_argument('--out-dir', default='backups', help='Backup destination directory.')

return parser.parse_args()


def main() -> int:
args = parse_args()

if args.command == 'init-key':
key = generate_api_key()
save_key(key)
print('Generated new GameCopier API key (save this now, it is not stored in plaintext):')
print(key)
return 0

if args.command == 'copy':
if not validate_api_key(args.api_key):
print('Invalid API key.', file=sys.stderr)
return 1

copied_to = copy_game_file(Path(args.source), Path(args.out_dir))
print(f'Backup created: {copied_to}')
return 0

return 1


if __name__ == '__main__':
raise SystemExit(main())
28 changes: 28 additions & 0 deletions script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const copyBtn = document.getElementById('copyBtn');
const gameFileInput = document.getElementById('gameFile');
const statusBox = document.getElementById('status');

function nowStamp() {
return new Date().toLocaleTimeString();
}

copyBtn.addEventListener('click', () => {
const file = gameFileInput.value.trim();

copyBtn.classList.remove('clicked');
void copyBtn.offsetWidth;
copyBtn.classList.add('clicked');

if (!file) {
statusBox.textContent = `[${nowStamp()}] Enter a local .rbxl or .rbxlx file path first.`;
return;
}

const isValid = file.endsWith('.rbxl') || file.endsWith('.rbxlx');
if (!isValid) {
statusBox.textContent = `[${nowStamp()}] Unsupported file. Use .rbxl or .rbxlx.`;
return;
}

statusBox.textContent = `[${nowStamp()}] Prepared local copy command for ${file} -> ./backups`;
});
Loading