Introduction
ROT13 shifts each letter by 13 positions in the alphabet, wrapping around from Z back to A. Common bugs in JavaScript implementations include not handling uppercase and lowercase separately, applying the shift to non-alphabetic characters (digits, punctuation), using incorrect character code ranges, and off-by-one errors in the wrapping logic. A correct implementation processes only A-Z and a-z while passing everything else through unchanged.
Correct Implementation
1function rot13(str) {
2 return str.replace(/[a-zA-Z]/g, function(char) {
3 const base = char <= 'Z' ? 65 : 97; // 'A' = 65, 'a' = 97
4 return String.fromCharCode(
5 ((char.charCodeAt(0) - base + 13) % 26) + base
6 );
7 });
8}
9
10console.log(rot13("Hello World!")); // "Uryyb Jbeyq!"
11console.log(rot13("Uryyb Jbeyq!")); // "Hello World!" (ROT13 is its own inverse)
The key formula: ((charCode - base + 13) % 26) + base
Bug 1: Not Preserving Case
1// WRONG: converts everything to uppercase
2function rot13Bad(str) {
3 return str.replace(/[a-zA-Z]/g, function(char) {
4 return String.fromCharCode(
5 ((char.toUpperCase().charCodeAt(0) - 65 + 13) % 26) + 65
6 );
7 });
8}
9
10rot13Bad("Hello"); // "URYYB", lost lowercase
Fix: Detect the case and use the appropriate base (65 for uppercase, 97 for lowercase):
const base = char.charCodeAt(0) < 97 ? 65 : 97;
1// WRONG: shifts digits and punctuation too
2function rot13Bad(str) {
3 let result = '';
4 for (let i = 0; i < str.length; i++) {
5 result += String.fromCharCode(str.charCodeAt(i) + 13);
6 }
7 return result;
8}
9
10rot13Bad("Hello 123!"); // "Uryy|-0./4", garbage for non-letters
Fix: Check if the character is a letter before shifting:
1function rot13(str) {
2 let result = '';
3 for (let i = 0; i < str.length; i++) {
4 const code = str.charCodeAt(i);
5 if (code >= 65 && code <= 90) {
6 result += String.fromCharCode(((code - 65 + 13) % 26) + 65);
7 } else if (code >= 97 && code <= 122) {
8 result += String.fromCharCode(((code - 97 + 13) % 26) + 97);
9 } else {
10 result += str[i]; // Pass through unchanged
11 }
12 }
13 return result;
14}
Bug 3: Wrong Wrapping Logic
1// WRONG: uses simple addition without modulo
2function rot13Bad(str) {
3 return str.replace(/[a-zA-Z]/g, function(char) {
4 const code = char.charCodeAt(0);
5 if (code >= 65 && code <= 90) {
6 return String.fromCharCode(code + 13); // No wrapping!
7 }
8 return char;
9 });
10}
11
12rot13Bad("XYZ"); // "efg", shifted past 'Z' into lowercase range
Without % 26, letters past M/m overflow into non-letter ASCII ranges. X (88) + 13 = 101 = e (wrong, should be K).
Bug 4: Off-by-One in Range Check
1// WRONG: excludes 'Z' and 'z'
2function rot13Bad(str) {
3 return str.replace(/[a-zA-Z]/g, function(char) {
4 const code = char.charCodeAt(0);
5 if (code >= 65 && code < 90) { // < instead of <=
6 return String.fromCharCode(((code - 65 + 13) % 26) + 65);
7 }
8 return char;
9 });
10}
11
12rot13Bad("AZ"); // "NZ", 'Z' is not rotated
Use <= for inclusive ranges: code >= 65 && code <= 90.
Bug 5: Using charCodeAt Without Arguments
1// WRONG: charCodeAt() defaults to index 0, but this is inside replace
2function rot13Bad(str) {
3 return str.split('').map(function(c) {
4 // c is a single character, so charCodeAt(0) is correct
5 // But some people write charCodeAt() without 0. It still works but is unclear
6 return String.fromCharCode(((c.charCodeAt() - 97 + 13) % 26) + 97);
7 }).join('');
8}
9
10rot13Bad("Hello"); // Wrong: uses 97 base for everything, breaks uppercase
Alternative: Lookup Table
For clarity and performance, use a precomputed mapping:
1const ROT13_MAP = {};
2for (let i = 0; i < 26; i++) {
3 const upper = String.fromCharCode(65 + i);
4 const lower = String.fromCharCode(97 + i);
5 ROT13_MAP[upper] = String.fromCharCode(65 + ((i + 13) % 26));
6 ROT13_MAP[lower] = String.fromCharCode(97 + ((i + 13) % 26));
7}
8
9function rot13(str) {
10 return str.replace(/[a-zA-Z]/g, char => ROT13_MAP[char]);
11}
12
13console.log(rot13("The Quick Brown Fox"));
14// "Gur Dhvpx Oebja Sbk"
Testing Your Implementation
ROT13 is its own inverse. Applying it twice returns the original:
1function testRot13(impl) {
2 const tests = [
3 ["Hello World!", "Uryyb Jbeyq!"],
4 ["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
5 "NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm"],
6 ["123 !@#", "123 !@#"], // Non-letters unchanged
7 ["", ""], // Empty string
8 ["AaBbZz", "NnOoMm"],
9 ];
10
11 tests.forEach(([input, expected]) => {
12 const result = impl(input);
13 const pass = result === expected;
14 console.log(`${pass ? 'PASS' : 'FAIL'}: rot13("${input}") = "${result}"`);
15
16 // Double rot13 should return original
17 const roundTrip = impl(impl(input));
18 console.assert(roundTrip === input, `Round trip failed for "${input}"`);
19 });
20}
21
22testRot13(rot13);
Other Cipher Variants
1// Generic Caesar cipher with any shift
2function caesarCipher(str, shift) {
3 shift = ((shift % 26) + 26) % 26; // Normalize negative shifts
4 return str.replace(/[a-zA-Z]/g, function(char) {
5 const base = char <= 'Z' ? 65 : 97;
6 return String.fromCharCode(((char.charCodeAt(0) - base + shift) % 26) + base);
7 });
8}
9
10caesarCipher("Hello", 13); // "Uryyb" (ROT13)
11caesarCipher("Hello", 1); // "Ifmmp" (ROT1)
12caesarCipher("Ifmmp", -1); // "Hello" (decrypt ROT1)
Common Pitfalls
Hardcoding ASCII values wrong: 'A' is 65, 'Z' is 90, 'a' is 97, 'z' is 122. Off-by-one in these constants breaks the entire cipher.
Forgetting modulo 26: Without % 26, letters in the second half of the alphabet (N-Z) produce character codes outside the letter range.
Treating uppercase and lowercase identically: Using a single base (65 or 97) for both cases corrupts the output. Detect the case per character.
Mutating non-letter characters: Digits, spaces, and punctuation must pass through unchanged. Only match [a-zA-Z].
Not testing round-trip: ROT13 applied twice must return the original string. If rot13(rot13(x)) !== x, the implementation is wrong.
Summary
ROT13 shifts letters by 13 positions: ((code - base + 13) % 26) + base
Use % 26 to wrap around the alphabet boundary
Handle uppercase (base 65) and lowercase (base 97) separately
Pass non-alphabetic characters through unchanged
ROT13 is its own inverse, meaning rot13(rot13(text)) === text
Use str.replace(/[a-zA-Z]/g, fn) for the cleanest implementation