When I implemented my web-based port of Taipan!, I added one minor feature: I let the user save (and load) a game to (or from) a single savegame ‘slot’. It can take a few hours to work your score up to the highest levels, so this seemed like a nice convenience feature. Today, I’d like to talk a little about the implementation of this feature.
What/When?
The most basic design decisions related to this feature are the closely related questions of what data will be saved, and when the user will be allowed to save. For instance, if we allow the user to save in the middle of combat, we’d probably want to store information related to the number of enemy ships surviving, the damage they’ve taken, the damage the player has taken, etc.
For the sake of simplicity, I decided that the player would only be allowed to save when in port, and not engaged in a ‘dialog’ (e.g. being extorted by Li Yuen, Buying, Selling, etc.) This meant that I only had to save the global game state, and didn’t have to worry about any ephemera.
Game State
My port of the game tracks global state as a collection of members of an ‘Application’ object; here is that object’s constructor as it existed before load/save was added:
function App() {
// Constants
this.GENERIC = 1
this.LI_YUEN = 2
// Game state
this.capacity = 60;
this.damage = 0;
this.hkw_ = [0, 0, 0, 0];
this.hold_ = [0, 0, 0, 0];
this.months = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
this.month = 1;
this.year = 1860;
this.location = [ "At sea", "Hong Kong", "Shanghai", "Nagasaki",
"Saigon", "Manila", "Singapore", "Batavia"];
this.st = ["Critical", " Poor", " Fair", " Good", " Prime", "Perfect"];
this.item = ["Opium","Silk","Arms","General Cargo"];
this.wu_warn = 0;
this.wu_bailout = 0;
this.ec = 20;
this.ed = .5;
this.booty = 0;
this.firm = undefined;
this.cash = undefined;
this.debt = undefined;
this.hold = undefined;
this.guns = undefined;
this.li = undefined;
this.bp = undefined;
this.bank = 0;
this.port = 1;
this.price = [];
this.base_price = [ [1000, 11, 16, 15, 14, 12, 10, 13],
[100, 11, 14, 15, 16, 10, 13, 12],
[10, 12, 16, 10, 11, 13, 14, 15],
[1, 10, 11, 12, 13, 14, 15, 16]];
// Setup
initscr();
// Main loop (CPS)
this.main();
}
My first task was to write functions that could save and load global state to and from a JavaScript string. (A string being a nice, simple data structure that I could stuff easily into some form of persistent storage or other). In order to avoid hard-coding as much as possible, I added ‘Meta’ information to the Application object: a list of the fields to be saved, and a version number (in case I ever changed the savegame format.) The new Application constructor looks like this:
function App() {
// Constants
this.GENERIC = 1
this.LI_YUEN = 2
// Meta state
this.version = 1
this.meta = [ 'version',
'capacity', 'damage', 'hkw_', 'hold_', 'month', 'year',
'wu_warn', 'wu_bailout', 'ec', 'ed',
'firm', 'cash', 'debt', 'hold', 'guns', 'li', 'bp',
'bank', 'port', 'price'];
// Game state
this.capacity = 60;
this.damage = 0;
this.hkw_ = [0, 0, 0, 0];
this.hold_ = [0, 0, 0, 0];
this.months = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
this.month = 1;
this.year = 1860;
this.location = [ "At sea", "Hong Kong", "Shanghai", "Nagasaki",
"Saigon", "Manila", "Singapore", "Batavia"];
this.st = ["Critical", " Poor", " Fair", " Good", " Prime", "Perfect"];
this.item = ["Opium","Silk","Arms","General Cargo"];
this.wu_warn = 0;
this.wu_bailout = 0;
this.ec = 20;
this.ed = .5;
this.booty = 0;
this.firm = undefined;
this.cash = undefined;
this.debt = undefined;
this.hold = undefined;
this.guns = undefined;
this.li = undefined;
this.bp = undefined;
this.bank = 0;
this.port = 1;
this.price = [];
this.base_price = [ [1000, 11, 16, 15, 14, 12, 10, 13],
[100, 11, 14, 15, 16, 10, 13, 12],
[10, 12, 16, 10, 11, 13, 14, 15],
[1, 10, 11, 12, 13, 14, 15, 16]];
// Setup
initscr();
// Main loop (CPS)
this.main();
}
I could then add the load from/save to a string functions:
App.prototype.marshall = function () {
for (var i = 0, rv = {}; i < this.meta.length; i++)
{
rv[this.meta[i]] = this[this.meta[i]];
}
return serializeJSON(rv);
}
App.prototype.unmarshall = function (s) {
for (var i = 0, rv = evalJSON(s); i < this.meta.length; i++)
{
this[this.meta[i]] = rv[this.meta[i]];
}
}
(This code is built around MochiKit, hence the serializeJSON()
and evalJSON()
calls.)
Cookies
Once I had decided what state to save, and had written code to save it to (and load it from) a JS string, I had to decide where to store that string. I didn’t want to write a full server-side solution, so the player’s browser – specifically the cookie store – seemed the most logical place. I wrote some utility functions to handle cookies:
function createCookie(name, value, days) {
var date, expires;
if (days)
{
date = new Date();
date.setTime(date.getTime() + days*24*60*60*1000);
expires = "; expires="+date.toGMTString();
}
else
{
expires = "";
}
document.cookie = name+"="+escape(value)+expires+"; path=/";
}
function readCookie(name) {
var nameEQ, ca, i, c;
nameEQ = name + "=";
ca = document.cookie.split(';');
for (i = 0; i < ca.length; i++)
{
c = ca[i];
while (c.charAt(0) == ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) == 0) return unescape(c.substring(nameEQ.length, c.length));
}
return null;
}
Game Logic
At this point, the only remaining task was to call the code shown above from the right place in the game code. Since I was restricting the user to using load and save at certain specific points in the game, it was simplest to hook the UI for load/save into the overall Taipan! UI (as opposed to floating buttons or other UI elements outside the main game UI). This required two simple functions to drive interaction with the user:
App.prototype.load = function (c) {
var app = this,
data;
move(16, 0);
clrtobot();
if (data = readCookie('taipan_savegame'))
{
move(18, 0);
printw("Are you sure you want to load your saved\n");
printw("game, and abandon your current one? ");
refresh();
app.get_one(confirm);
}
else
{
move(18, 0);
printw("No saved game available");
refresh();
app.flash_msg(3000, c);
}
function confirm(yn)
{
if ((yn != 'Y') && (yn != 'N'))
{
move(19, 0);
clrtoeol();
printw("game, and abandon your current one? ");
refresh();
return app.get_one(confirm);
}
if (yn == 'Y')
{
app.unmarshall(data);
app.port_stats();
}
c();
}
};
App.prototype.save = function (c) {
var app = this,
data;
move(16, 0);
clrtobot();
if (data = readCookie('taipan_savegame'))
{
move(18, 0);
printw("Are you sure you want to overwrite your\n");
printw("saved game with the current one? ");
refresh();
app.get_one(confirm);
}
else
{
confirm('Y');
}
function confirm(yn)
{
if ((yn != 'Y') && (yn != 'N'))
{
move(19, 0);
clrtoeol();
printw("saved game with the current one? ");
refresh();
return app.get_one(confirm);
}
if (yn == 'Y')
{
createCookie('taipan_savegame', app.marshall(), 365);
printw("\n\nGame saved to your browser");
refresh();
app.flash_msg(3000, c);
}
else
{
printw("\n\nSave canceled");
refresh();
app.flash_msg(3000, c);
}
}
};
These functions naturally had to be called from the right point in the game loop, but that code is really too straight-forward (and implementation specific) to go into. At any rate, that’s all there is to it; I hope you found it interesting.