Source: Fork/customization of open-source text-engine by okaybenji Repository: https://github.com/okaybenji/text-engine Version: 2.0.0 License: GPL
Engine File: index.js (26,081 bytes)
Game Data: game-disks/p-n-k-forever.js (JavaScript object/JSON)
Frontend: index.html (minimal HTML structure)
Styling: styles/retro.css (terminal aesthetic)
Disk Metaphor: The game uses a “disk” metaphor from floppy disk era. The entire game is represented as a single JavaScript object (the “disk”) that gets loaded into the engine.
const talesOfPhonixAndK = {
roomId: "beach_rest", // Starting location
rooms: [...], // All locations
characters: [...], // All NPCs
inventory: [], // Player's items
// Custom properties can be added
};
loadDisk(talesOfPhonixAndK);
The engine provides a global commands array organized by argument count:
commands[0] - No arguments:
look - Describe current roomitems - List items in roominv - List inventorysave - Save game to localStorageload - Load game from localStoragehelp - Show help menugo - List available exitscommands[1] - One argument:
take [item] - Pick up an itemuse [item] - Use an itemgo [direction] - Move in a directioncommands[2] - Two arguments:
look at [item] - Examine an itemtalk to [character] - Start conversationfly to [location] - (Custom, added dynamically)P&K Forever adds the FLY command:
const fly = (name) => {
if (name && name.length > 0) {
flyTo([null, name]);
} else {
flyTo(name);
}
};
const flyTo = (args) => {
const [_, name] = args;
const flyableRooms = getFlyableRooms();
const flyableRoomsNames = flyableRooms.map((r) => r.id);
if (flyableRoomsNames.includes(name)) {
const destRoom = flyableRooms.find((r) => r.id === name);
if (disk.roomId === destRoom.isFlyableFrom) {
println(`You fly to ${name}!`);
if (typeof destRoom.onFly === "function") {
destRoom.onFly({ disk, println });
}
const k = getCharacter("k");
if (k.agreedToTravel) {
k.roomId = name;
}
enterRoom(name);
} else {
println(`You can't fly to ${name} from ${disk.roomId}!`);
}
} else {
if (name) {
println(`You don't know how to fly to ${name}.`);
}
println("You hover in the sky! 🐦");
}
};
// Register with command system
commands[0].fly = fly;
commands[1].fly = fly;
commands[2] = Object.assign(commands[2], { fly: flyTo });
const updateHelpCommand = (additionalCommands) => {
help = () =>
println(`
The following commands are available:
========================================================
LOOK :: repeat room description
LOOK AT [OBJECT NAME] e.g. 'look at key'
TAKE [OBJECT NAME] e.g. 'take book'
TALK TO [CHARACTER NAME] e.g. 'talk to mary'
GO [DIRECTION] e.g. 'go north'
USE [OBJECT NAME] e.g. 'use door'
${additionalCommands || ``}
ITEMS: list items in the room
INV :: list inventory items
SAVE: save the current game
LOAD: load the last saved game
HELP :: this help menu
`);
commands[0].help = help;
};
updateHelpCommand(); // Initial call
// Later, when FLY is unlocked:
updateHelpCommand(`FLY TO [ROOM NAME] e.g. 'fly to room'`);
Retro Theme (styles/retro.css) - Used by P&K Forever:
Modern Theme (styles/modern.css) - Available but not used:
document.getElementById("styles").setAttribute("href", "styles/retro.css");
Ultimate Apple II Font from KreativeKorp
fonts/font.ttffonts/font-license.txtStorage: Browser localStorage Format: JSON string with function serialization
Save Function:
let save = (name = 'save') => {
const save = JSON.stringify(disk, (key, value) =>
typeof value === 'function' ? value.toString() : value
);
localStorage.setItem(name, save);
println(`Game saved.`)
};
Load Function:
let load = (name = 'save') => {
const save = localStorage.getItem(name);
if (!save) {
println(`Save file not found.`);
return;
}
disk = JSON.parse(save, (key, value) => {
try {
return eval(value); // Deserialize functions
} catch (error) {
return value;
}
});
println(`Game loaded.`)
enterRoom(disk.roomId);
};
onUse: () => { ... }onUse() { ... }eval() is used for function deserializationService: IFTTT Maker Webhooks Purpose: Trigger real-world actions from game events
Implementation:
fetch(`https://maker.ifttt.com/trigger/{EVENT_NAME}/with/key/buN0S2VUtrVLjyoCLowl7X`)
Events Used:
pnk_mango - Mango preference revealedpnk_drink - Tea preference revealedpnk_chocolate - Chocolate preference revealedpnk_kite - Kite equipment discoveredpnk_love - Love declaration madepnk_fly - Necklace used, ready to fly homeIFTTT Applet Setup (hypothetical):
Each event would have corresponding IFTTT applet:
Example for pnk_chocolate:
IF Maker Event "pnk_chocolate" received
THEN
- Send notification to phone
- Or trigger smart home action
- Or send email
- Or add to shopping list
- Or [any IFTTT integration]
Security Notes:
Status Badge:
[](https://pnk-forever.vercel.app)
Configuration (vercel.json):
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "cd v1-modern && npm install && npm run build",
"outputDirectory": "v1-modern/dist",
"framework": null,
"cleanUrls": true,
"redirects": [
{ "source": "/v0-original-text-engine", "destination": "/v0-original-text-engine/", "permanent": false },
{ "source": "/v0", "destination": "/v0-original-text-engine/", "permanent": false }
]
}
Deployment:
v1-modern/ via Vite; v0 served as static assetsmainSite URLs:
Minimum:
Tested On:
Not Supported:
None used - assumes modern browser environment
Input Element:
<input id="input" autofocus spellcheck="false">
Event Listeners:
ENTER key - Submit command:
input.addEventListener('keypress', (e) => {
const ENTER = 13;
if (e.keyCode === ENTER) {
applyInput();
}
});
UP/DOWN arrows - Command history:
input.addEventListener('keydown', (e) => {
const UP = 38;
const DOWN = 40;
const TAB = 9;
if (e.keyCode === UP) {
navigateHistory('prev');
} else if (e.keyCode === DOWN) {
navigateHistory('next');
} else if (e.keyCode === TAB) {
e.preventDefault();
autocomplete();
}
});
Focus management:
input.addEventListener('focusout', () => {
input.focus({preventScroll: true});
});
let inputs = ['']; // History of commands
let inputsPos = 0; // Current position in history
TAB key triggers autocomplete:
<div id="output"></div>
let println = (line, className) => {
const output = document.querySelector('#output');
const lineElement = document.createElement('div');
if (className) {
lineElement.classList.add(className);
}
// Handle arrays (random selection)
if (Array.isArray(line)) {
line = pickOne(line);
}
lineElement.innerHTML = line;
output.appendChild(lineElement);
// Auto-scroll to bottom
output.scrollTop = output.scrollHeight;
};
Features:
loadDisk(talesOfPhonixAndK);
Process:
init(disk) - Add default valuessetup() - Register event listenersenterRoom(disk.roomId) - Enter starting roomenterRoom(id) {
const room = getRoom(id);
// Update disk state
disk.roomId = id;
// Increment visit counter
room.visits++;
// Display room
if (room.img) println(room.img);
println(room.name, 'room-name');
println(room.desc);
// List characters
const characters = getCharactersInRoom(id);
if (characters.length) {
println(`Characters: ${characters.map(c => c.name[0]).join(', ')}`);
}
// List items
if (room.items && room.items.length) {
println(`Items: ${room.items.map(i => i.name[0]).join(', ')}`);
}
// Callback
if (typeof room.onEnter === 'function') {
room.onEnter();
}
}
applyInput() calledpickOne(arr) - Random array element:
let pickOne = (arr) => arr[Math.floor(Math.random() * arr.length)];
getRoom(id) - Find room by ID:
let getRoom = (id) => disk.rooms.find(room => room.id === id);
getCharacter(name) - Find character by name:
let getCharacter = (name, chars = disk.characters) => {
return chars.find(char => {
const names = Array.isArray(char.name) ? char.name : [char.name];
return names.includes(name);
});
};
getExit(dir, exits) - Find exit by direction:
let getExit = (dir, exits) => {
return exits.find(exit => exit.dir === dir);
};
getFlyableRooms() - Get rooms that can be flown to:
const getFlyableRooms = () => {
return disk.rooms.filter((r) => r.isFlyableFrom);
};
room.visits = 0; // Initialized for each room
Incremented each time room is entered. Could be used for:
(Not heavily used in P&K Forever, but available)
Characters can move between rooms:
const k = getCharacter("k");
k.roomId = "beach_sunset"; // Move K to new location
Items can be added to rooms dynamically:
const street = getRoom("jaffa_street");
street.items.push({
name: ["Kite Board"],
desc: "Your kite equipment",
// ...
});
Exits can be added/removed:
// Add exit
street.exits.push({
dir: "north",
id: "beach_sunset",
});
// Remove exit block
const exit = getExit("east", room.exits);
delete exit.block;
k.roomTopics = {}; // Storage for location-specific topics
k.roomTopics["beach"] = k.topics; // Save current topics
k.topics = k.roomTopics["kitchen"] || [ /* new topics */ ]; // Switch
Allows characters to have different conversations in different locations while preserving history.
const debug_room = null; // "japan"
const talesOfPhonixAndK = {
roomId: debug_room || "beach_rest",
// ...
};
Set debug_room to skip to specific location for testing.
All game functions are global, allowing console debugging:
// In browser console:
disk.roomId = "japan";
enterRoom("japan");
getCharacter("k").roomId = "japan";
disk.inventory.push({name: "test item"});
Good:
Could Improve:
Safe:
Potential Concerns:
eval() in load function (mitigated by localStorage-only access)Would need:
Any additions should honor the original’s elegance:
The original P&K Forever makes powerful choices:
When modernizing, we must ask: “Does this addition serve the story and the player, or just the technology?”
The goal isn’t to show off what’s possible - it’s to honor what’s meaningful.